mirror of
https://github.com/prosolis/gogobee.git
synced 2026-07-15 00:32:40 +00:00
Rewrite from TypeScript to Go
Complete rewrite of the Freebee Matrix bot as GogoBee using mautrix-go. - E2EE with goolm (pure Go, no CGo/libolm) and cross-signing bootstrap - 35+ plugins with dependency injection and ordered registration - SQLite storage via modernc.org/sqlite (no CGo) - Scheduler via robfig/cron for WOTD, holidays, birthdays, releases, etc. - Optional LLM integration (Ollama) for sentiment, profanity, roasts, vibes - Threaded trivia, three-tier profanity tracking, WOTD usage verification - Multi-country holiday support with deduplication - Quadratic XP curve, configurable bot display name, encrypted DMs Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
60
.env.example
60
.env.example
@@ -1,57 +1,39 @@
|
|||||||
# Matrix
|
# ---- Matrix Connection ----
|
||||||
MATRIX_HOMESERVER_URL=https://matrix.example.com
|
HOMESERVER_URL=https://matrix.example.com
|
||||||
MATRIX_ACCESS_TOKEN=syt_your_token_here
|
BOT_USER_ID=@gogobee:example.com
|
||||||
MATRIX_BOT_USER_ID=@freebee:example.com
|
BOT_PASSWORD=your_password_here
|
||||||
MATRIX_BOT_PASSWORD=
|
BOT_DISPLAY_NAME=GogoBee
|
||||||
|
|
||||||
# Which rooms the bot actively posts scheduled content to (comma-separated)
|
# Which rooms the bot posts scheduled content to (comma-separated room IDs)
|
||||||
BOT_ROOMS=!roomid:example.com
|
BROADCAST_ROOMS=!roomid:example.com
|
||||||
|
|
||||||
# Admins who can run admin-only commands
|
# Admins who can run admin-only commands (comma-separated Matrix user IDs)
|
||||||
BOT_ADMIN_USERS=@yourmxid:example.com
|
ADMIN_USERS=@yourmxid:example.com
|
||||||
|
|
||||||
# Runtime
|
# ---- Runtime ----
|
||||||
BOT_PREFIX=!
|
|
||||||
DATA_DIR=./data
|
DATA_DIR=./data
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
|
|
||||||
# External APIs
|
# ---- External API Keys (all optional) ----
|
||||||
RAWG_API_KEY=
|
RAWG_API_KEY=
|
||||||
WORDNIK_API_KEY=
|
WORDNIK_API_KEY=
|
||||||
CALENDARIFIC_API_KEY=
|
CALENDARIFIC_API_KEY=
|
||||||
ASIAN_HOLIDAY_COUNTRIES=JP,CN,KR,IN,TH,VN,TW,PH
|
HOLIDAY_COUNTRIES=US,GB,CA,AU,PT,IN,JP,DE,FR,BR,MX,IT,ES,KR,NL,SE,NO,IE,NZ,ZA,PH
|
||||||
HEBCAL_API_KEY=
|
|
||||||
OPENWEATHER_API_KEY=
|
OPENWEATHER_API_KEY=
|
||||||
FINNHUB_API_KEY=
|
FINNHUB_API_KEY=
|
||||||
BANDSINTOWN_API_KEY=
|
BANDSINTOWN_API_KEY=
|
||||||
TMDB_API_KEY=
|
TMDB_API_KEY=
|
||||||
|
|
||||||
|
# ---- Self-Hosted Services (optional) ----
|
||||||
LIBRETRANSLATE_URL=
|
LIBRETRANSLATE_URL=
|
||||||
|
|
||||||
# Scheduler times (24h UTC)
|
|
||||||
SCHEDULE_HOLIDAYS_HOUR=7
|
|
||||||
SCHEDULE_HOLIDAYS_MINUTE=0
|
|
||||||
SCHEDULE_RELEASES_HOUR=19
|
|
||||||
SCHEDULE_RELEASES_MINUTE=0
|
|
||||||
|
|
||||||
# Feature flags
|
|
||||||
FEATURE_SHADE=false
|
|
||||||
FEATURE_URL_PREVIEW=true
|
|
||||||
|
|
||||||
# Crypto reset (set to true to wipe and re-establish E2EE keys on next startup)
|
|
||||||
CRYPTO_RESET=false
|
|
||||||
|
|
||||||
# LLM passive classifier (requires Ollama — leave blank to disable)
|
|
||||||
OLLAMA_HOST=
|
OLLAMA_HOST=
|
||||||
OLLAMA_MODEL=
|
OLLAMA_MODEL=
|
||||||
LLM_SAMPLE_RATE=0.15
|
LLM_SAMPLE_RATE=0.15 # 0.0-1.0, fraction of non-keyword messages to classify (1.0 = all)
|
||||||
|
|
||||||
# Rate limits (calls per user per day, 0 = unlimited)
|
# ---- Feature Flags ----
|
||||||
RATELIMIT_WEATHER=5
|
FEATURE_URL_PREVIEW=
|
||||||
|
FEATURE_SHADE=
|
||||||
|
FEATURE_TRIVIA=true # set to "false" to disable trivia
|
||||||
|
|
||||||
|
# ---- Rate Limits (per user per day) ----
|
||||||
RATELIMIT_TRANSLATE=20
|
RATELIMIT_TRANSLATE=20
|
||||||
RATELIMIT_CONCERTS=10
|
|
||||||
|
|
||||||
# Bot default city for concert digest and location-based features
|
|
||||||
BOT_DEFAULT_CITY=
|
|
||||||
|
|
||||||
# Trivia
|
|
||||||
TRIVIA_TIMEOUT_SECONDS=20
|
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,8 +1,5 @@
|
|||||||
dist/
|
|
||||||
data/
|
data/
|
||||||
.env
|
.env
|
||||||
.env.*
|
.env.*
|
||||||
!.env.example
|
!.env.example
|
||||||
node_modules/
|
gogobee
|
||||||
merge_env.py
|
|
||||||
Twinbee Build Plan.md
|
|
||||||
|
|||||||
20
Dockerfile
20
Dockerfile
@@ -1,12 +1,18 @@
|
|||||||
FROM node:20-alpine
|
FROM golang:1.24-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
COPY package.json package-lock.json* ./
|
COPY . .
|
||||||
RUN npm ci --omit=dev
|
RUN CGO_ENABLED=0 go build -tags goolm -o gogobee .
|
||||||
|
|
||||||
COPY tsconfig.json ./
|
FROM alpine:3.21
|
||||||
COPY src/ ./src/
|
RUN apk add --no-cache ca-certificates tzdata
|
||||||
RUN npx tsc
|
WORKDIR /app
|
||||||
|
COPY --from=builder /app/gogobee .
|
||||||
|
|
||||||
CMD ["node", "dist/index.js"]
|
VOLUME /app/data
|
||||||
|
ENV DATA_DIR=/app/data
|
||||||
|
|
||||||
|
CMD ["./gogobee"]
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
services:
|
services:
|
||||||
freebee:
|
gogobee:
|
||||||
build: .
|
build: .
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
env_file: .env
|
env_file: .env
|
||||||
volumes:
|
volumes:
|
||||||
# CRITICAL: The crypto/ subdirectory contains the bot's device identity.
|
# Contains the SQLite database (bot state + crypto store) and device.json.
|
||||||
# Losing it means the bot must re-verify in all encrypted rooms.
|
# Losing this means the bot must re-verify in all encrypted rooms.
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
|||||||
43
go.mod
Normal file
43
go.mod
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
module gogobee
|
||||||
|
|
||||||
|
go 1.25.0
|
||||||
|
|
||||||
|
require (
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.3
|
||||||
|
github.com/expr-lang/expr v1.17.5
|
||||||
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
|
github.com/olebedev/when v1.1.0
|
||||||
|
github.com/robfig/cron/v3 v3.0.1
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
|
||||||
|
maunium.net/go/mautrix v0.26.3
|
||||||
|
modernc.org/sqlite v1.37.1
|
||||||
|
)
|
||||||
|
|
||||||
|
require (
|
||||||
|
filippo.io/edwards25519 v1.1.0 // indirect
|
||||||
|
github.com/AlekSi/pointer v1.0.0 // indirect
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 // indirect
|
||||||
|
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||||
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 // indirect
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 // indirect
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 // indirect
|
||||||
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||||
|
github.com/rs/zerolog v1.34.0 // indirect
|
||||||
|
github.com/tidwall/gjson v1.18.0 // indirect
|
||||||
|
github.com/tidwall/match v1.1.1 // indirect
|
||||||
|
github.com/tidwall/pretty v1.2.1 // indirect
|
||||||
|
github.com/tidwall/sjson v1.2.5 // indirect
|
||||||
|
go.mau.fi/util v0.9.6 // indirect
|
||||||
|
golang.org/x/crypto v0.48.0 // indirect
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||||
|
golang.org/x/net v0.50.0 // indirect
|
||||||
|
golang.org/x/sys v0.41.0 // indirect
|
||||||
|
golang.org/x/text v0.34.0 // indirect
|
||||||
|
modernc.org/libc v1.65.7 // indirect
|
||||||
|
modernc.org/mathutil v1.7.1 // indirect
|
||||||
|
modernc.org/memory v1.11.0 // indirect
|
||||||
|
)
|
||||||
177
go.sum
Normal file
177
go.sum
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
||||||
|
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
||||||
|
github.com/AlekSi/pointer v1.0.0 h1:KWCWzsvFxNLcmM5XmiqHsGTTsuwZMsLFwWF9Y+//bNE=
|
||||||
|
github.com/AlekSi/pointer v1.0.0/go.mod h1:1kjywbfcPFCmncIxtk6fIEub6LKrfMz3gc5QKVOSOA8=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
|
||||||
|
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo=
|
||||||
|
github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
|
||||||
|
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||||
|
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||||
|
github.com/expr-lang/expr v1.17.5 h1:i1WrMvcdLF249nSNlpQZN1S6NXuW9WaOfF5tPi3aw3k=
|
||||||
|
github.com/expr-lang/expr v1.17.5/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||||
|
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||||
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||||
|
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||||
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp6Zk=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
|
||||||
|
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||||
|
github.com/olebedev/when v1.1.0 h1:dlpoRa7huImhNtEx4yl0WYfTHVEWmJmIWd7fEkTHayc=
|
||||||
|
github.com/olebedev/when v1.1.0/go.mod h1:T0THb4kP9D3NNqlvCwIG4GyUioTAzEhB4RNVzig/43E=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741 h1:KPpdlQLZcHfTMQRi6bFQ7ogNO0ltFT4PmtwTLW4W+14=
|
||||||
|
github.com/petermattis/goid v0.0.0-20260113132338-7c7de50cc741/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4=
|
||||||
|
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||||
|
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||||
|
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||||
|
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
|
github.com/tidwall/gjson v1.14.2/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
|
||||||
|
github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
|
||||||
|
github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
|
||||||
|
github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
|
||||||
|
github.com/tidwall/pretty v1.2.0/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
|
||||||
|
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
|
||||||
|
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
|
||||||
|
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
|
||||||
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
|
go.mau.fi/util v0.9.6 h1:2nsvxm49KhI3wrFltr0+wSUBlnQ4CMtykuELjpIU+ts=
|
||||||
|
go.mau.fi/util v0.9.6/go.mod h1:sIJpRH7Iy5Ad1SBuxQoatxtIeErgzxCtjd/2hCMkYMI=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
|
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
|
||||||
|
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||||
|
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a h1:ovFr6Z0MNmU7nH8VaX5xqw+05ST2uO1exVfZPVqRC5o=
|
||||||
|
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA=
|
||||||
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
|
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
|
||||||
|
golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=
|
||||||
|
golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=
|
||||||
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
|
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||||
|
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||||
|
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||||
|
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||||
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
|
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
||||||
|
golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
||||||
|
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
|
||||||
|
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
|
||||||
|
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
|
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
|
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||||
|
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
|
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||||
|
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||||
|
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
||||||
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
|
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
|
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||||
|
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||||
|
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
||||||
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
|
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||||
|
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
|
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||||
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
|
golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k=
|
||||||
|
golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0=
|
||||||
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
maunium.net/go/mautrix v0.26.3 h1:tWZih6Vjw0qGTWuPmg9JUrQPzViTNDPGQLVc5UXC4nk=
|
||||||
|
maunium.net/go/mautrix v0.26.3/go.mod h1:v5ZdDoCwUpNqEj5OrhEoUa3L1kEddKPaAya9TgGXN38=
|
||||||
|
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
|
||||||
|
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
|
||||||
|
modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE=
|
||||||
|
modernc.org/fileutil v1.3.1 h1:8vq5fe7jdtEvoCf3Zf9Nm0Q05sH6kGx0Op2CPx1wTC8=
|
||||||
|
modernc.org/fileutil v1.3.1/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
|
||||||
|
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
|
||||||
|
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
|
||||||
|
modernc.org/libc v1.65.7 h1:Ia9Z4yzZtWNtUIuiPuQ7Qf7kxYrxP1/jeHZzG8bFu00=
|
||||||
|
modernc.org/libc v1.65.7/go.mod h1:011EQibzzio/VX3ygj1qGFt5kMjP0lHb0qCW5/D/pQU=
|
||||||
|
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||||
|
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||||
|
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
|
||||||
|
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||||
|
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
||||||
|
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||||
|
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||||
|
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||||
|
modernc.org/sqlite v1.37.1 h1:EgHJK/FPoqC+q2YBXg7fUmES37pCHFc97sI7zSayBEs=
|
||||||
|
modernc.org/sqlite v1.37.1/go.mod h1:XwdRtsE1MpiBcL54+MbKcaDvcuej+IYSMfLN6gSKV8g=
|
||||||
|
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||||
|
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||||
|
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||||
|
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
|
||||||
204
internal/bot/client.go
Normal file
204
internal/bot/client.go
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"gogobee/internal/util"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/crypto/cryptohelper"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeviceInfo holds persisted device credentials.
|
||||||
|
type DeviceInfo struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Config holds the bot's startup configuration.
|
||||||
|
type Config struct {
|
||||||
|
Homeserver string
|
||||||
|
UserID string
|
||||||
|
Password string
|
||||||
|
DataDir string
|
||||||
|
DisplayName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClient creates and configures a mautrix client with E2EE support.
|
||||||
|
// The cryptohelper handles:
|
||||||
|
// - Persistent crypto store in SQLite (device keys, sessions, cross-signing keys)
|
||||||
|
// - Automatic cross-signing bootstrap (self-signs the device on first run)
|
||||||
|
// - Automatic device trust via cross-signing (no manual verification needed)
|
||||||
|
// - Megolm session sharing and key exchange
|
||||||
|
// - Olm session management for device-to-device encryption
|
||||||
|
//
|
||||||
|
// This solves the TS version's device verification issues because:
|
||||||
|
// 1. Crypto state persists across restarts (not in-memory like fake-indexeddb)
|
||||||
|
// 2. Cross-signing makes other devices trust this bot automatically
|
||||||
|
// 3. The bot trusts all users' devices by default (appropriate for a bot)
|
||||||
|
// 4. No manual emoji/SAS verification needed
|
||||||
|
func NewClient(cfg Config) (*mautrix.Client, error) {
|
||||||
|
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
|
||||||
|
return nil, fmt.Errorf("create data dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
devicePath := filepath.Join(cfg.DataDir, "device.json")
|
||||||
|
|
||||||
|
// Try to load existing device credentials
|
||||||
|
device, err := loadDevice(devicePath)
|
||||||
|
if err != nil {
|
||||||
|
slog.Info("no existing device found, will login fresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
var client *mautrix.Client
|
||||||
|
|
||||||
|
if device != nil {
|
||||||
|
// Validate existing token
|
||||||
|
valid, _ := util.IsTokenValid(cfg.Homeserver, device.AccessToken)
|
||||||
|
if valid {
|
||||||
|
slog.Info("existing device credentials valid", "device_id", device.DeviceID)
|
||||||
|
userID := id.UserID(device.UserID)
|
||||||
|
client, err = mautrix.NewClient(cfg.Homeserver, userID, device.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create client with existing token: %w", err)
|
||||||
|
}
|
||||||
|
client.DeviceID = id.DeviceID(device.DeviceID)
|
||||||
|
} else {
|
||||||
|
slog.Warn("existing device credentials invalid, logging in again")
|
||||||
|
device = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if device == nil {
|
||||||
|
// Fresh login
|
||||||
|
loginResp, err := util.LoginWithPassword(cfg.Homeserver, cfg.UserID, cfg.Password, cfg.DisplayName)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("login: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID := id.UserID(loginResp.UserID)
|
||||||
|
client, err = mautrix.NewClient(cfg.Homeserver, userID, loginResp.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create client: %w", err)
|
||||||
|
}
|
||||||
|
client.DeviceID = id.DeviceID(loginResp.DeviceID)
|
||||||
|
|
||||||
|
// Save device info
|
||||||
|
device = &DeviceInfo{
|
||||||
|
AccessToken: loginResp.AccessToken,
|
||||||
|
DeviceID: loginResp.DeviceID,
|
||||||
|
UserID: loginResp.UserID,
|
||||||
|
}
|
||||||
|
if err := saveDevice(devicePath, device); err != nil {
|
||||||
|
slog.Warn("failed to save device info", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("logged in successfully",
|
||||||
|
"user_id", loginResp.UserID,
|
||||||
|
"device_id", loginResp.DeviceID,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set up E2EE via cryptohelper — stores crypto state in its own SQLite DB,
|
||||||
|
// separate from the main app database. Unlike the TS version which used an
|
||||||
|
// in-memory fake-indexeddb store that was lost on restart (causing constant
|
||||||
|
// re-verification), mautrix-go's cryptohelper persists everything in SQLite:
|
||||||
|
// device keys, olm/megolm sessions, cross-signing keys, and device trust state.
|
||||||
|
//
|
||||||
|
// We pass just the raw file path — the cryptohelper wraps it in a file: URI
|
||||||
|
// with _txlock=immediate internally (see cryptohelper.go line 82).
|
||||||
|
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
|
||||||
|
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("init crypto helper: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginAs enables the cryptohelper to re-login if the token expires,
|
||||||
|
// and to bootstrap cross-signing on first run. Cross-signing means:
|
||||||
|
// - The bot's master key signs its own device key
|
||||||
|
// - Other users/devices that have verified the bot's master key
|
||||||
|
// will automatically trust this device
|
||||||
|
// - No interactive emoji/SAS verification needed
|
||||||
|
ch.LoginAs = &mautrix.ReqLogin{
|
||||||
|
Type: mautrix.AuthTypePassword,
|
||||||
|
Identifier: mautrix.UserIdentifier{
|
||||||
|
Type: mautrix.IdentifierTypeUser,
|
||||||
|
User: cfg.UserID,
|
||||||
|
},
|
||||||
|
Password: cfg.Password,
|
||||||
|
InitialDeviceDisplayName: cfg.DisplayName,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ch.Init(context.Background()); err != nil {
|
||||||
|
return nil, fmt.Errorf("crypto helper init: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Attach crypto helper to client
|
||||||
|
client.Crypto = ch
|
||||||
|
|
||||||
|
// Bootstrap cross-signing: generate keys, sign own device, sign master key.
|
||||||
|
// This makes the bot's device show as "verified" to other users.
|
||||||
|
mach := ch.Machine()
|
||||||
|
recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(context.Background(), func(ui *mautrix.RespUserInteractive) interface{} {
|
||||||
|
return map[string]interface{}{
|
||||||
|
"type": mautrix.AuthTypePassword,
|
||||||
|
"identifier": map[string]interface{}{
|
||||||
|
"type": mautrix.IdentifierTypeUser,
|
||||||
|
"user": cfg.UserID,
|
||||||
|
},
|
||||||
|
"password": cfg.Password,
|
||||||
|
"session": ui.Session,
|
||||||
|
}
|
||||||
|
}, "")
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("cross-signing: key upload failed (may already exist)", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("cross-signing: keys uploaded", "recovery_key", recoveryKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mach.SignOwnDevice(context.Background(), mach.OwnIdentity()); err != nil {
|
||||||
|
slog.Warn("cross-signing: sign own device failed", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("cross-signing: own device signed")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := mach.SignOwnMasterKey(context.Background()); err != nil {
|
||||||
|
slog.Warn("cross-signing: sign master key failed", "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("cross-signing: master key signed")
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("E2EE initialized",
|
||||||
|
"device_id", client.DeviceID,
|
||||||
|
"crypto_store", "sqlite-persistent",
|
||||||
|
)
|
||||||
|
|
||||||
|
return client, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadDevice(path string) (*DeviceInfo, error) {
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var info DeviceInfo
|
||||||
|
if err := json.Unmarshal(data, &info); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &info, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func saveDevice(path string, info *DeviceInfo) error {
|
||||||
|
data, err := json.MarshalIndent(info, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, 0o600)
|
||||||
|
}
|
||||||
90
internal/bot/dispatch.go
Normal file
90
internal/bot/dispatch.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package bot
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"gogobee/internal/plugin"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Registry manages plugin registration and event dispatch.
|
||||||
|
type Registry struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
plugins []plugin.Plugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRegistry creates an empty plugin registry.
|
||||||
|
func NewRegistry() *Registry {
|
||||||
|
return &Registry{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register adds a plugin to the registry.
|
||||||
|
func (r *Registry) Register(p plugin.Plugin) {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
r.plugins = append(r.plugins, p)
|
||||||
|
slog.Info("registered plugin", "name", p.Name())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Init initializes all registered plugins.
|
||||||
|
func (r *Registry) Init() error {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, p := range r.plugins {
|
||||||
|
if err := p.Init(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchMessage sends a message context to all plugins in order.
|
||||||
|
func (r *Registry) DispatchMessage(ctx plugin.MessageContext) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, p := range r.plugins {
|
||||||
|
if err := p.OnMessage(ctx); err != nil {
|
||||||
|
slog.Error("plugin message handler error",
|
||||||
|
"plugin", p.Name(),
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DispatchReaction sends a reaction context to all plugins in order.
|
||||||
|
func (r *Registry) DispatchReaction(ctx plugin.ReactionContext) {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, p := range r.plugins {
|
||||||
|
if err := p.OnReaction(ctx); err != nil {
|
||||||
|
slog.Error("plugin reaction handler error",
|
||||||
|
"plugin", p.Name(),
|
||||||
|
"err", err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetCommands returns all command definitions from all plugins.
|
||||||
|
func (r *Registry) GetCommands() []plugin.CommandDef {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
var cmds []plugin.CommandDef
|
||||||
|
for _, p := range r.plugins {
|
||||||
|
cmds = append(cmds, p.Commands()...)
|
||||||
|
}
|
||||||
|
return cmds
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPlugin returns a plugin by name.
|
||||||
|
func (r *Registry) GetPlugin(name string) plugin.Plugin {
|
||||||
|
r.mu.RLock()
|
||||||
|
defer r.mu.RUnlock()
|
||||||
|
for _, p := range r.plugins {
|
||||||
|
if p.Name() == name {
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
541
internal/db/db.go
Normal file
541
internal/db/db.go
Normal file
@@ -0,0 +1,541 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
mu sync.Mutex
|
||||||
|
globalDB *sql.DB
|
||||||
|
)
|
||||||
|
|
||||||
|
// Init opens (or creates) the SQLite database and runs migrations.
|
||||||
|
func Init(dataDir string) error {
|
||||||
|
mu.Lock()
|
||||||
|
defer mu.Unlock()
|
||||||
|
|
||||||
|
if globalDB != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||||
|
return fmt.Errorf("create data dir: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := filepath.Join(dataDir, "gogobee.db")
|
||||||
|
d, err := sql.Open("sqlite", dbPath+"?_pragma=journal_mode(WAL)&_pragma=busy_timeout(5000)&_pragma=foreign_keys(ON)")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open database: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d.SetMaxOpenConns(1) // SQLite is single-writer
|
||||||
|
|
||||||
|
if err := runMigrations(d); err != nil {
|
||||||
|
return fmt.Errorf("run migrations: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
globalDB = d
|
||||||
|
slog.Info("database initialized", "path", dbPath)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the global database handle. Panics if Init was not called.
|
||||||
|
func Get() *sql.DB {
|
||||||
|
if globalDB == nil {
|
||||||
|
panic("db.Get() called before db.Init()")
|
||||||
|
}
|
||||||
|
return globalDB
|
||||||
|
}
|
||||||
|
|
||||||
|
func runMigrations(d *sql.DB) error {
|
||||||
|
_, err := d.Exec(schema)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// JobCompleted checks if a scheduled job has already completed for the given date key.
|
||||||
|
// Use date "2006-01-02" for daily jobs, or "2006-W01" style for weekly jobs.
|
||||||
|
func JobCompleted(jobName, dateKey string) bool {
|
||||||
|
var completed int
|
||||||
|
err := Get().QueryRow(
|
||||||
|
`SELECT completed FROM daily_prefetch WHERE job_name = ? AND date = ?`,
|
||||||
|
jobName, dateKey,
|
||||||
|
).Scan(&completed)
|
||||||
|
return err == nil && completed == 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkJobCompleted marks a scheduled job as completed for the given date key.
|
||||||
|
func MarkJobCompleted(jobName, dateKey string) {
|
||||||
|
_, err := Get().Exec(
|
||||||
|
`INSERT INTO daily_prefetch (job_name, date, completed) VALUES (?, ?, 1)
|
||||||
|
ON CONFLICT(job_name, date) DO UPDATE SET completed = 1`,
|
||||||
|
jobName, dateKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("mark job completed", "job", jobName, "date", dateKey, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
-- Users & XP
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
display_name TEXT DEFAULT '',
|
||||||
|
xp INTEGER DEFAULT 0,
|
||||||
|
level INTEGER DEFAULT 0,
|
||||||
|
last_xp_at INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_stats (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
total_messages INTEGER DEFAULT 0,
|
||||||
|
total_words INTEGER DEFAULT 0,
|
||||||
|
total_chars INTEGER DEFAULT 0,
|
||||||
|
total_links INTEGER DEFAULT 0,
|
||||||
|
total_images INTEGER DEFAULT 0,
|
||||||
|
total_questions INTEGER DEFAULT 0,
|
||||||
|
total_exclamations INTEGER DEFAULT 0,
|
||||||
|
total_emojis INTEGER DEFAULT 0,
|
||||||
|
night_messages INTEGER DEFAULT 0,
|
||||||
|
morning_messages INTEGER DEFAULT 0,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS xp_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
amount INTEGER NOT NULL,
|
||||||
|
reason TEXT DEFAULT '',
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reputation
|
||||||
|
CREATE TABLE IF NOT EXISTS rep_cooldowns (
|
||||||
|
giver TEXT NOT NULL,
|
||||||
|
receiver TEXT NOT NULL,
|
||||||
|
last_given INTEGER DEFAULT (unixepoch()),
|
||||||
|
PRIMARY KEY (giver, receiver)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reminders
|
||||||
|
CREATE TABLE IF NOT EXISTS reminders (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
fire_at INTEGER NOT NULL,
|
||||||
|
fired INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_reminders_fire ON reminders(fired, fire_at);
|
||||||
|
|
||||||
|
-- Daily activity / streaks
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_activity (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
message_count INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_first (
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
timestamp INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (room_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Word of the Day
|
||||||
|
CREATE TABLE IF NOT EXISTS wotd_log (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
word TEXT NOT NULL,
|
||||||
|
definition TEXT NOT NULL,
|
||||||
|
part_of_speech TEXT DEFAULT '',
|
||||||
|
example TEXT DEFAULT '',
|
||||||
|
posted INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS wotd_usage (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
count INTEGER DEFAULT 0,
|
||||||
|
rewarded INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Holidays
|
||||||
|
CREATE TABLE IF NOT EXISTS holidays_log (
|
||||||
|
date TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
posted INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Game releases
|
||||||
|
CREATE TABLE IF NOT EXISTS releases_cache (
|
||||||
|
cache_key TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
cached_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS release_watchlist (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
game_name TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, game_name)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- HLTB cache
|
||||||
|
CREATE TABLE IF NOT EXISTS hltb_cache (
|
||||||
|
game_name TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
cached_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Achievements
|
||||||
|
CREATE TABLE IF NOT EXISTS achievements (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
achievement_id TEXT NOT NULL,
|
||||||
|
unlocked_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
PRIMARY KEY (user_id, achievement_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Quotes
|
||||||
|
CREATE TABLE IF NOT EXISTS quotes (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
quote_text TEXT NOT NULL,
|
||||||
|
saved_by TEXT NOT NULL,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Now Playing
|
||||||
|
CREATE TABLE IF NOT EXISTS now_playing (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
track TEXT NOT NULL,
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Backlog
|
||||||
|
CREATE TABLE IF NOT EXISTS backlog (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
item TEXT NOT NULL,
|
||||||
|
done INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Predictions (stub/future)
|
||||||
|
CREATE TABLE IF NOT EXISTS predictions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
prediction TEXT NOT NULL,
|
||||||
|
outcome TEXT DEFAULT '',
|
||||||
|
resolved INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Keyword watches
|
||||||
|
CREATE TABLE IF NOT EXISTS keyword_watches (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
keyword TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, keyword)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Scheduler config
|
||||||
|
CREATE TABLE IF NOT EXISTS scheduler_config (
|
||||||
|
job_name TEXT PRIMARY KEY,
|
||||||
|
enabled INTEGER DEFAULT 1,
|
||||||
|
cron_expr TEXT NOT NULL,
|
||||||
|
last_run TEXT DEFAULT ''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Shade (stub)
|
||||||
|
CREATE TABLE IF NOT EXISTS shade_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
target_user TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS shade_optout (
|
||||||
|
user_id TEXT PRIMARY KEY
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Birthdays
|
||||||
|
CREATE TABLE IF NOT EXISTS birthdays (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
month INTEGER NOT NULL,
|
||||||
|
day INTEGER NOT NULL,
|
||||||
|
year INTEGER DEFAULT 0,
|
||||||
|
timezone TEXT DEFAULT 'UTC'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS birthday_fired (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
year INTEGER NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, year)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Trivia
|
||||||
|
CREATE TABLE IF NOT EXISTS trivia_sessions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
category INTEGER DEFAULT 0,
|
||||||
|
difficulty TEXT DEFAULT 'medium',
|
||||||
|
question TEXT NOT NULL,
|
||||||
|
correct_answer TEXT NOT NULL,
|
||||||
|
incorrect_answers TEXT NOT NULL,
|
||||||
|
question_type TEXT DEFAULT 'multiple',
|
||||||
|
thread_id TEXT DEFAULT '',
|
||||||
|
started_at INTEGER DEFAULT (unixepoch()),
|
||||||
|
ended INTEGER DEFAULT 0,
|
||||||
|
winner_id TEXT DEFAULT '',
|
||||||
|
winner_time_ms INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS trivia_scores (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
correct INTEGER DEFAULT 0,
|
||||||
|
wrong INTEGER DEFAULT 0,
|
||||||
|
total_score INTEGER DEFAULT 0,
|
||||||
|
fastest_ms INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, room_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- LLM classifications
|
||||||
|
CREATE TABLE IF NOT EXISTS llm_classifications (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
message_text TEXT NOT NULL,
|
||||||
|
sentiment TEXT DEFAULT '',
|
||||||
|
sentiment_score REAL DEFAULT 0,
|
||||||
|
topics TEXT DEFAULT '[]',
|
||||||
|
profanity INTEGER DEFAULT 0,
|
||||||
|
profanity_severity INTEGER DEFAULT 0,
|
||||||
|
insult_target TEXT DEFAULT '',
|
||||||
|
wotd_used INTEGER DEFAULT 0,
|
||||||
|
gratitude_target TEXT DEFAULT '',
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS potty_mouth (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
count INTEGER DEFAULT 0,
|
||||||
|
mild INTEGER DEFAULT 0,
|
||||||
|
moderate INTEGER DEFAULT 0,
|
||||||
|
scorching INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS insult_log (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
times_insulted INTEGER DEFAULT 0,
|
||||||
|
times_insulting INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Stocks
|
||||||
|
CREATE TABLE IF NOT EXISTS stocks_cache (
|
||||||
|
ticker TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
cached_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS stock_watchlist (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
ticker TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, ticker)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Command usage
|
||||||
|
CREATE TABLE IF NOT EXISTS command_usage (
|
||||||
|
command TEXT NOT NULL,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
count INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (command, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Concerts
|
||||||
|
CREATE TABLE IF NOT EXISTS concerts_cache (
|
||||||
|
artist TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
cached_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS concert_watchlist (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
artist TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, artist)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Anime
|
||||||
|
CREATE TABLE IF NOT EXISTS anime_watchlist (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
mal_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, mal_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS anime_cache (
|
||||||
|
mal_id INTEGER PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
cached_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Movies
|
||||||
|
CREATE TABLE IF NOT EXISTS movie_watchlist (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
tmdb_id INTEGER NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
media_type TEXT DEFAULT 'movie',
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
PRIMARY KEY (user_id, tmdb_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS movie_cache (
|
||||||
|
tmdb_id INTEGER PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
cached_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Countdowns
|
||||||
|
CREATE TABLE IF NOT EXISTS countdowns (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
label TEXT NOT NULL,
|
||||||
|
target_date TEXT NOT NULL,
|
||||||
|
public INTEGER DEFAULT 1,
|
||||||
|
completed INTEGER DEFAULT 0,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Presence
|
||||||
|
CREATE TABLE IF NOT EXISTS presence (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
status TEXT DEFAULT 'online',
|
||||||
|
message TEXT DEFAULT '',
|
||||||
|
updated_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Markov
|
||||||
|
CREATE TABLE IF NOT EXISTS markov_corpus (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
text TEXT NOT NULL,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_markov_user ON markov_corpus(user_id);
|
||||||
|
|
||||||
|
-- Retro/game lookup cache
|
||||||
|
CREATE TABLE IF NOT EXISTS retro_cache (
|
||||||
|
search_term TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
cached_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Urban Dictionary cache
|
||||||
|
CREATE TABLE IF NOT EXISTS urban_cache (
|
||||||
|
term TEXT PRIMARY KEY,
|
||||||
|
data TEXT NOT NULL,
|
||||||
|
cached_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Room milestones
|
||||||
|
CREATE TABLE IF NOT EXISTS room_milestones (
|
||||||
|
room_id TEXT PRIMARY KEY,
|
||||||
|
total_messages INTEGER DEFAULT 0,
|
||||||
|
last_milestone INTEGER DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Reaction log
|
||||||
|
CREATE TABLE IF NOT EXISTS reaction_log (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
room_id TEXT NOT NULL,
|
||||||
|
event_id TEXT NOT NULL,
|
||||||
|
sender TEXT NOT NULL,
|
||||||
|
target_user TEXT NOT NULL,
|
||||||
|
emoji TEXT NOT NULL,
|
||||||
|
created_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Sentiment stats (aggregated)
|
||||||
|
CREATE TABLE IF NOT EXISTS sentiment_stats (
|
||||||
|
user_id TEXT PRIMARY KEY,
|
||||||
|
positive INTEGER DEFAULT 0,
|
||||||
|
negative INTEGER DEFAULT 0,
|
||||||
|
neutral INTEGER DEFAULT 0,
|
||||||
|
total_score REAL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Daily prefetch tracking
|
||||||
|
CREATE TABLE IF NOT EXISTS daily_prefetch (
|
||||||
|
job_name TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
completed INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (job_name, date)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- URL preview cache
|
||||||
|
CREATE TABLE IF NOT EXISTS url_cache (
|
||||||
|
url TEXT PRIMARY KEY,
|
||||||
|
title TEXT DEFAULT '',
|
||||||
|
description TEXT DEFAULT '',
|
||||||
|
cached_at INTEGER DEFAULT (unixepoch())
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Rate limits
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limits (
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
count INTEGER DEFAULT 0,
|
||||||
|
PRIMARY KEY (user_id, action, date)
|
||||||
|
);
|
||||||
|
`
|
||||||
|
|
||||||
|
// SeedSchedulerDefaults inserts default scheduler jobs if they don't exist.
|
||||||
|
func SeedSchedulerDefaults(d *sql.DB) error {
|
||||||
|
defaults := []struct {
|
||||||
|
name string
|
||||||
|
cron string
|
||||||
|
}{
|
||||||
|
{"prefetch", "5 0 * * *"}, // 00:05 daily
|
||||||
|
{"maintenance", "0 3 * * *"}, // 03:00 daily
|
||||||
|
{"wotd", "0 8 * * *"}, // 08:00 daily
|
||||||
|
{"holidays", "0 7 * * *"}, // 07:00 daily
|
||||||
|
{"releases", "0 9 * * 1"}, // 09:00 Monday
|
||||||
|
{"birthday_check", "0 6 * * *"}, // 06:00 daily
|
||||||
|
{"anime_releases", "0 10 * * *"},// 10:00 daily
|
||||||
|
{"movie_releases", "0 11 * * *"},// 11:00 daily
|
||||||
|
{"concert_digest", "0 12 * * 0"},// 12:00 Sunday
|
||||||
|
}
|
||||||
|
|
||||||
|
stmt, err := d.Prepare(`INSERT OR IGNORE INTO scheduler_config (job_name, cron_expr) VALUES (?, ?)`)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer stmt.Close()
|
||||||
|
|
||||||
|
for _, def := range defaults {
|
||||||
|
if _, err := stmt.Exec(def.name, def.cron); err != nil {
|
||||||
|
return fmt.Errorf("seed scheduler %s: %w", def.name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
476
internal/plugin/achievements.go
Normal file
476
internal/plugin/achievements.go
Normal file
@@ -0,0 +1,476 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AchievementRegistry provides cross-plugin query access for achievements.
|
||||||
|
type AchievementRegistry interface {
|
||||||
|
GetCommands() []CommandDef
|
||||||
|
}
|
||||||
|
|
||||||
|
// achievementDef describes a single achievement with its check function.
|
||||||
|
type achievementDef struct {
|
||||||
|
ID string
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Emoji string
|
||||||
|
Check func(d *sql.DB, userID id.UserID) bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// AchievementsPlugin checks and grants achievements silently on every message.
|
||||||
|
type AchievementsPlugin struct {
|
||||||
|
Base
|
||||||
|
registry AchievementRegistry
|
||||||
|
achievements []achievementDef
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAchievementsPlugin creates a new achievements plugin.
|
||||||
|
func NewAchievementsPlugin(client *mautrix.Client, registry AchievementRegistry) *AchievementsPlugin {
|
||||||
|
p := &AchievementsPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
registry: registry,
|
||||||
|
}
|
||||||
|
p.achievements = p.buildAchievements()
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AchievementsPlugin) Name() string { return "achievements" }
|
||||||
|
|
||||||
|
func (p *AchievementsPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "achievements", Description: "List unlocked achievements", Usage: "!achievements [@user]", Category: "Leveling & Stats"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AchievementsPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *AchievementsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *AchievementsPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "achievements") {
|
||||||
|
return p.handleAchievements(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive: check achievements for the sender
|
||||||
|
p.checkAndGrant(ctx.Sender)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkAndGrant evaluates all achievement definitions and grants any newly unlocked ones.
|
||||||
|
func (p *AchievementsPlugin) checkAndGrant(userID id.UserID) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Batch-fetch already unlocked achievements to avoid redundant checks
|
||||||
|
unlocked := make(map[string]bool)
|
||||||
|
rows, err := d.Query(`SELECT achievement_id FROM achievements WHERE user_id = ?`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("achievements: query unlocked", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
for rows.Next() {
|
||||||
|
var aid string
|
||||||
|
if err := rows.Scan(&aid); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
unlocked[aid] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only check achievements not yet unlocked
|
||||||
|
for _, ach := range p.achievements {
|
||||||
|
if unlocked[ach.ID] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if ach.Check(d, userID) {
|
||||||
|
p.grant(d, userID, ach.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AchievementsPlugin) grant(d *sql.DB, userID id.UserID, achievementID string) {
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO achievements (user_id, achievement_id) VALUES (?, ?) ON CONFLICT DO NOTHING`,
|
||||||
|
string(userID), achievementID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("achievements: grant", "user", userID, "achievement", achievementID, "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("achievements: granted", "user", userID, "achievement", achievementID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AchievementsPlugin) handleAchievements(ctx MessageContext) error {
|
||||||
|
target := ctx.Sender
|
||||||
|
args := p.GetArgs(ctx.Body, "achievements")
|
||||||
|
if args != "" {
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
if cleaned != "" {
|
||||||
|
target = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT achievement_id, unlocked_at FROM achievements WHERE user_id = ? ORDER BY unlocked_at ASC`,
|
||||||
|
string(target),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("achievements: list query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load achievements.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
// Build a lookup map from achievement defs
|
||||||
|
defMap := make(map[string]achievementDef)
|
||||||
|
for _, a := range p.achievements {
|
||||||
|
defMap[a.ID] = a
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Achievements for %s:\n\n", string(target)))
|
||||||
|
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var achID string
|
||||||
|
var unlockedAt int64
|
||||||
|
if err := rows.Scan(&achID, &unlockedAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
def, ok := defMap[achID]
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t := time.Unix(unlockedAt, 0).UTC().Format("2006-01-02")
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s — %s (unlocked %s)\n", def.Emoji, def.Name, def.Description, t))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s hasn't unlocked any achievements yet.", string(target)))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%d / %d achievements unlocked", count, len(p.achievements)))
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildAchievements returns all 32 achievement definitions.
|
||||||
|
func (p *AchievementsPlugin) buildAchievements() []achievementDef {
|
||||||
|
return []achievementDef{
|
||||||
|
// Message milestones
|
||||||
|
{
|
||||||
|
ID: "first_message", Name: "First Steps", Description: "Sent your first message",
|
||||||
|
Emoji: "👶",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 1) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "100_messages", Name: "Chatterbox", Description: "Sent 100 messages",
|
||||||
|
Emoji: "💬",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 100) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "1000_messages", Name: "Motor Mouth", Description: "Sent 1,000 messages",
|
||||||
|
Emoji: "🗣️",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 1000) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "10000_messages", Name: "Legend", Description: "Sent 10,000 messages",
|
||||||
|
Emoji: "🏛️",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 10000) },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Time-based
|
||||||
|
{
|
||||||
|
ID: "night_owl", Name: "Night Owl", Description: "Sent 100 messages between midnight and 6am",
|
||||||
|
Emoji: "🦉",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "night_messages", 100) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "early_bird", Name: "Early Bird", Description: "Sent 100 messages between 5am and 9am",
|
||||||
|
Emoji: "🐦",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "morning_messages", 100) },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Content
|
||||||
|
{
|
||||||
|
ID: "wordsmith", Name: "Wordsmith", Description: "Average message length over 8 words",
|
||||||
|
Emoji: "✍️",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
var totalMessages, totalWords int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT total_messages, total_words FROM user_stats WHERE user_id = ?`,
|
||||||
|
string(u),
|
||||||
|
).Scan(&totalMessages, &totalWords)
|
||||||
|
if err != nil || totalMessages < 50 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return float64(totalWords)/float64(totalMessages) > 8.0
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "link_collector", Name: "Link Collector", Description: "Shared 50 links",
|
||||||
|
Emoji: "🔗",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_links", 50) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "shutterbug", Name: "Shutterbug", Description: "Shared 20 images",
|
||||||
|
Emoji: "📸",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_images", 20) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "question_master", Name: "Question Master", Description: "Asked 100 questions",
|
||||||
|
Emoji: "❓",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_questions", 100) },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Social
|
||||||
|
{
|
||||||
|
ID: "welcome_wagon", Name: "Welcome Wagon", Description: "Welcomed a new member to the community",
|
||||||
|
Emoji: "👋",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
// This is checked externally when a new user joins
|
||||||
|
// Here we just check if it was already granted
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rep_magnet", Name: "Rep Magnet", Description: "Received 10 reputation points",
|
||||||
|
Emoji: "💜",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
var total int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
|
||||||
|
string(u),
|
||||||
|
).Scan(&total)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return total/5 >= 10
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Streaks
|
||||||
|
{
|
||||||
|
ID: "week_streak", Name: "Weekly Warrior", Description: "Active for 7 consecutive days",
|
||||||
|
Emoji: "📅",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 7) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "month_streak", Name: "Monthly Marvel", Description: "Active for 30 consecutive days",
|
||||||
|
Emoji: "🗓️",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 30) },
|
||||||
|
},
|
||||||
|
|
||||||
|
// Trivia
|
||||||
|
{
|
||||||
|
ID: "trivia_novice", Name: "Trivia Novice", Description: "Answered 10 trivia questions correctly",
|
||||||
|
Emoji: "🧠",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
var correct int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(correct), 0) FROM trivia_scores WHERE user_id = ?`,
|
||||||
|
string(u),
|
||||||
|
).Scan(&correct)
|
||||||
|
return err == nil && correct >= 10
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "trivia_master", Name: "Trivia Master", Description: "Answered 100 trivia questions correctly",
|
||||||
|
Emoji: "🎓",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
var correct int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(correct), 0) FROM trivia_scores WHERE user_id = ?`,
|
||||||
|
string(u),
|
||||||
|
).Scan(&correct)
|
||||||
|
return err == nil && correct >= 100
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Special
|
||||||
|
{
|
||||||
|
ID: "markov_victim", Name: "Markov Victim", Description: "Had your words remixed by the Markov chain",
|
||||||
|
Emoji: "🤖",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
// Granted externally by the markov plugin
|
||||||
|
return false
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "logophile", Name: "Logophile", Description: "Used 10 different Words of the Day",
|
||||||
|
Emoji: "📖",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
var count int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
|
||||||
|
string(u),
|
||||||
|
).Scan(&count)
|
||||||
|
return err == nil && count >= 10
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Additional milestones to reach 32 total
|
||||||
|
{
|
||||||
|
ID: "emoji_lover", Name: "Emoji Lover", Description: "Used 500 emojis in messages",
|
||||||
|
Emoji: "😍",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_emojis", 500) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "exclaimer", Name: "Exclaimer", Description: "Used 200 exclamation marks",
|
||||||
|
Emoji: "❗",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_exclamations", 200) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "two_week_streak", Name: "Fortnight Fighter", Description: "Active for 14 consecutive days",
|
||||||
|
Emoji: "⚔️",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 14) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "quarter_streak", Name: "Seasonal Sage", Description: "Active for 90 consecutive days",
|
||||||
|
Emoji: "🌿",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 90) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "500_messages", Name: "Conversationalist", Description: "Sent 500 messages",
|
||||||
|
Emoji: "🎙️",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 500) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "5000_messages", Name: "Veteran", Description: "Sent 5,000 messages",
|
||||||
|
Emoji: "🎖️",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_messages", 5000) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "link_hoarder", Name: "Link Hoarder", Description: "Shared 200 links",
|
||||||
|
Emoji: "🌐",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_links", 200) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "photographer", Name: "Photographer", Description: "Shared 100 images",
|
||||||
|
Emoji: "🖼️",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "total_images", 100) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "trivia_legend", Name: "Trivia Legend", Description: "Answered 500 trivia questions correctly",
|
||||||
|
Emoji: "👑",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
var correct int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(correct), 0) FROM trivia_scores WHERE user_id = ?`,
|
||||||
|
string(u),
|
||||||
|
).Scan(&correct)
|
||||||
|
return err == nil && correct >= 500
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "rep_star", Name: "Reputation Star", Description: "Received 50 reputation points",
|
||||||
|
Emoji: "⭐",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
var total int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
|
||||||
|
string(u),
|
||||||
|
).Scan(&total)
|
||||||
|
return err == nil && total/5 >= 50
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "night_dweller", Name: "Night Dweller", Description: "Sent 500 night messages",
|
||||||
|
Emoji: "🌙",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "night_messages", 500) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "dawn_patrol", Name: "Dawn Patrol", Description: "Sent 500 morning messages",
|
||||||
|
Emoji: "🌅",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return statGTE(d, u, "morning_messages", 500) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "wotd_apprentice", Name: "WOTD Apprentice", Description: "Used 5 different Words of the Day",
|
||||||
|
Emoji: "📝",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool {
|
||||||
|
var count int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COUNT(DISTINCT date) FROM wotd_usage WHERE user_id = ? AND count > 0`,
|
||||||
|
string(u),
|
||||||
|
).Scan(&count)
|
||||||
|
return err == nil && count >= 5
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ID: "year_streak", Name: "Year-Long Devotion", Description: "Active for 365 consecutive days",
|
||||||
|
Emoji: "🏆",
|
||||||
|
Check: func(d *sql.DB, u id.UserID) bool { return checkStreak(d, u, 365) },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// statGTE checks if a user_stats column is >= threshold.
|
||||||
|
func statGTE(d *sql.DB, userID id.UserID, column string, threshold int) bool {
|
||||||
|
var val int
|
||||||
|
query := fmt.Sprintf(`SELECT COALESCE(%s, 0) FROM user_stats WHERE user_id = ?`, column)
|
||||||
|
err := d.QueryRow(query, string(userID)).Scan(&val)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return val >= threshold
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkStreak checks if a user has been active for N consecutive days ending today (or recently).
|
||||||
|
func checkStreak(d *sql.DB, userID id.UserID, days int) bool {
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT date FROM daily_activity WHERE user_id = ? ORDER BY date DESC LIMIT ?`,
|
||||||
|
string(userID), days+7, // fetch a few extra to find the streak
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
dates := make(map[string]bool)
|
||||||
|
for rows.Next() {
|
||||||
|
var date string
|
||||||
|
if err := rows.Scan(&date); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dates[date] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dates) < days {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for a consecutive run of `days` days ending at any recent date
|
||||||
|
today := time.Now().UTC()
|
||||||
|
for offset := 0; offset < 7; offset++ {
|
||||||
|
streak := 0
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
dateStr := today.AddDate(0, 0, -(offset + i)).Format("2006-01-02")
|
||||||
|
if dates[dateStr] {
|
||||||
|
streak++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if streak >= days {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantAchievement allows external plugins to grant specific achievements.
|
||||||
|
func (p *AchievementsPlugin) GrantAchievement(userID id.UserID, achievementID string) {
|
||||||
|
d := db.Get()
|
||||||
|
p.grant(d, userID, achievementID)
|
||||||
|
}
|
||||||
550
internal/plugin/anime.go
Normal file
550
internal/plugin/anime.go
Normal file
@@ -0,0 +1,550 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// jikanAnime represents an anime entry from the Jikan API.
|
||||||
|
type jikanAnime struct {
|
||||||
|
MalID int `json:"mal_id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
TitleEng string `json:"title_english"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Episodes int `json:"episodes"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Score float64 `json:"score"`
|
||||||
|
Synopsis string `json:"synopsis"`
|
||||||
|
Aired struct {
|
||||||
|
String string `json:"string"`
|
||||||
|
From string `json:"from"`
|
||||||
|
To string `json:"to"`
|
||||||
|
} `json:"aired"`
|
||||||
|
Broadcast struct {
|
||||||
|
Day string `json:"day"`
|
||||||
|
Time string `json:"time"`
|
||||||
|
TZ string `json:"timezone"`
|
||||||
|
} `json:"broadcast"`
|
||||||
|
Genres []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"genres"`
|
||||||
|
Studios []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"studios"`
|
||||||
|
Season string `json:"season"`
|
||||||
|
Year int `json:"year"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jikanSearchResponse is the Jikan search API response.
|
||||||
|
type jikanSearchResponse struct {
|
||||||
|
Data []jikanAnime `json:"data"`
|
||||||
|
Pagination struct {
|
||||||
|
HasNext int `json:"has_next_page"`
|
||||||
|
} `json:"pagination"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// jikanSeasonResponse is the Jikan season API response.
|
||||||
|
type jikanSeasonResponse struct {
|
||||||
|
Data []jikanAnime `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnimePlugin provides anime lookups via the Jikan/MAL API.
|
||||||
|
type AnimePlugin struct {
|
||||||
|
Base
|
||||||
|
httpClient *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
lastCall time.Time // for rate limiting Jikan (400ms between calls)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAnimePlugin creates a new AnimePlugin.
|
||||||
|
func NewAnimePlugin(client *mautrix.Client) *AnimePlugin {
|
||||||
|
return &AnimePlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) Name() string { return "anime" }
|
||||||
|
|
||||||
|
func (p *AnimePlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "anime", Description: "Search for anime info", Usage: "!anime <title>", Category: "Entertainment"},
|
||||||
|
{Name: "anime watch", Description: "Add anime to your watchlist", Usage: "!anime watch <title>", Category: "Entertainment"},
|
||||||
|
{Name: "anime watching", Description: "List your anime watchlist", Usage: "!anime watching", Category: "Entertainment"},
|
||||||
|
{Name: "anime unwatch", Description: "Remove anime from watchlist by MAL ID", Usage: "!anime unwatch <id>", Category: "Entertainment"},
|
||||||
|
{Name: "anime season", Description: "Show current season anime", Usage: "!anime season", Category: "Entertainment"},
|
||||||
|
{Name: "anime upcoming", Description: "Show upcoming anime", Usage: "!anime upcoming", Category: "Entertainment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *AnimePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *AnimePlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.IsCommand(ctx.Body, "anime") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := p.GetArgs(ctx.Body, "anime")
|
||||||
|
if args == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Usage: !anime <title> | !anime watch|watching|unwatch|season|upcoming")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(args, " ", 2)
|
||||||
|
sub := strings.ToLower(parts[0])
|
||||||
|
|
||||||
|
switch sub {
|
||||||
|
case "watch":
|
||||||
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime watch <title>")
|
||||||
|
}
|
||||||
|
return p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
||||||
|
case "watching":
|
||||||
|
return p.handleWatching(ctx)
|
||||||
|
case "unwatch":
|
||||||
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !anime unwatch <id>")
|
||||||
|
}
|
||||||
|
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
||||||
|
case "season":
|
||||||
|
return p.handleSeason(ctx)
|
||||||
|
case "upcoming":
|
||||||
|
return p.handleUpcoming(ctx)
|
||||||
|
default:
|
||||||
|
return p.handleSearch(ctx, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// rateLimit enforces the 400ms delay between Jikan API calls.
|
||||||
|
func (p *AnimePlugin) rateLimit() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
elapsed := time.Since(p.lastCall)
|
||||||
|
if elapsed < 400*time.Millisecond {
|
||||||
|
time.Sleep(400*time.Millisecond - elapsed)
|
||||||
|
}
|
||||||
|
p.lastCall = time.Now()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) jikanGet(apiURL string, target interface{}) error {
|
||||||
|
p.rateLimit()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("jikan returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.NewDecoder(resp.Body).Decode(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) handleSearch(ctx MessageContext, query string) error {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Search via API
|
||||||
|
encoded := url.QueryEscape(query)
|
||||||
|
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?q=%s&limit=3&sfw=true", encoded)
|
||||||
|
|
||||||
|
var searchResp jikanSearchResponse
|
||||||
|
if err := p.jikanGet(apiURL, &searchResp); err != nil {
|
||||||
|
slog.Error("anime: search failed", "query", query, "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for anime.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Data) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No anime found for \"%s\".", query))
|
||||||
|
}
|
||||||
|
|
||||||
|
anime := searchResp.Data[0]
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
data, _ := json.Marshal(anime)
|
||||||
|
if _, err := d.Exec(
|
||||||
|
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
|
); err != nil {
|
||||||
|
slog.Error("anime: cache write", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatAnime(anime))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) formatAnime(a jikanAnime) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
title := a.Title
|
||||||
|
if a.TitleEng != "" && a.TitleEng != a.Title {
|
||||||
|
title = fmt.Sprintf("%s (%s)", a.TitleEng, a.Title)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("%s\n", title))
|
||||||
|
sb.WriteString(fmt.Sprintf("MAL ID: %d\n", a.MalID))
|
||||||
|
|
||||||
|
if a.Type != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Type: %s", a.Type))
|
||||||
|
if a.Episodes > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(" (%d episodes)", a.Episodes))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Status != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Status: %s\n", a.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Score > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Score: %.2f/10\n", a.Score))
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Aired.String != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Aired: %s\n", a.Aired.String))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(a.Genres) > 0 {
|
||||||
|
genres := make([]string, len(a.Genres))
|
||||||
|
for i, g := range a.Genres {
|
||||||
|
genres[i] = g.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(a.Studios) > 0 {
|
||||||
|
studios := make([]string, len(a.Studios))
|
||||||
|
for i, s := range a.Studios {
|
||||||
|
studios[i] = s.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Studios: %s\n", strings.Join(studios, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Broadcast.Day != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Broadcast: %s %s (%s)\n", a.Broadcast.Day, a.Broadcast.Time, a.Broadcast.TZ))
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.Synopsis != "" {
|
||||||
|
synopsis := a.Synopsis
|
||||||
|
if len(synopsis) > 300 {
|
||||||
|
synopsis = synopsis[:300] + "..."
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s\n", synopsis))
|
||||||
|
}
|
||||||
|
|
||||||
|
if a.URL != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s", a.URL))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) handleWatch(ctx MessageContext, title string) error {
|
||||||
|
// Search for the anime first to get its MAL ID
|
||||||
|
encoded := url.QueryEscape(title)
|
||||||
|
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime?q=%s&limit=1&sfw=true", encoded)
|
||||||
|
|
||||||
|
var searchResp jikanSearchResponse
|
||||||
|
if err := p.jikanGet(apiURL, &searchResp); err != nil {
|
||||||
|
slog.Error("anime: watch search failed", "title", title, "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for anime.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Data) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No anime found for \"%s\".", title))
|
||||||
|
}
|
||||||
|
|
||||||
|
anime := searchResp.Data[0]
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO anime_watchlist (user_id, mal_id, title, room_id) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, mal_id) DO NOTHING`,
|
||||||
|
string(ctx.Sender), anime.MalID, anime.Title, string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("anime: watchlist add", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also cache the anime data
|
||||||
|
data, _ := json.Marshal(anime)
|
||||||
|
_, _ = d.Exec(
|
||||||
|
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
anime.MalID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
|
)
|
||||||
|
|
||||||
|
displayTitle := anime.Title
|
||||||
|
if anime.TitleEng != "" {
|
||||||
|
displayTitle = anime.TitleEng
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Added \"%s\" (MAL ID: %d) to your watchlist.", displayTitle, anime.MalID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) handleWatching(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT mal_id, title FROM anime_watchlist WHERE user_id = ? ORDER BY title`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("anime: watching list", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Your Anime Watchlist:\n\n")
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var malID int
|
||||||
|
var title string
|
||||||
|
if err := rows.Scan(&malID, &title); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" [%d] %s\n", malID, title))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Your anime watchlist is empty. Use !anime watch <title> to add anime.")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\nUse !anime unwatch <id> to remove an entry.")
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) handleUnwatch(ctx MessageContext, idStr string) error {
|
||||||
|
malID, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Please provide a valid MAL ID number. Check !anime watching for your list.")
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
res, err := d.Exec(
|
||||||
|
`DELETE FROM anime_watchlist WHERE user_id = ? AND mal_id = ?`,
|
||||||
|
string(ctx.Sender), malID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("anime: unwatch", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("MAL ID %d is not in your watchlist.", malID))
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed MAL ID %d from your watchlist.", malID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) handleSeason(ctx MessageContext) error {
|
||||||
|
apiURL := "https://api.jikan.moe/v4/seasons/now?limit=10&sfw=true"
|
||||||
|
|
||||||
|
var resp jikanSeasonResponse
|
||||||
|
if err := p.jikanGet(apiURL, &resp); err != nil {
|
||||||
|
slog.Error("anime: season fetch failed", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch current season anime.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Data) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No anime found for the current season.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Current Season Anime:\n\n")
|
||||||
|
|
||||||
|
for i, a := range resp.Data {
|
||||||
|
title := a.Title
|
||||||
|
if a.TitleEng != "" {
|
||||||
|
title = a.TitleEng
|
||||||
|
}
|
||||||
|
scoreStr := "N/A"
|
||||||
|
if a.Score > 0 {
|
||||||
|
scoreStr = fmt.Sprintf("%.1f", a.Score)
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s [%s] - Score: %s\n", i+1, title, a.Type, scoreStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *AnimePlugin) handleUpcoming(ctx MessageContext) error {
|
||||||
|
apiURL := "https://api.jikan.moe/v4/seasons/upcoming?limit=10&sfw=true"
|
||||||
|
|
||||||
|
var resp jikanSeasonResponse
|
||||||
|
if err := p.jikanGet(apiURL, &resp); err != nil {
|
||||||
|
slog.Error("anime: upcoming fetch failed", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch upcoming anime.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Data) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No upcoming anime found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Upcoming Anime:\n\n")
|
||||||
|
|
||||||
|
for i, a := range resp.Data {
|
||||||
|
title := a.Title
|
||||||
|
if a.TitleEng != "" {
|
||||||
|
title = a.TitleEng
|
||||||
|
}
|
||||||
|
info := a.Type
|
||||||
|
if a.Aired.String != "" {
|
||||||
|
info += " | " + a.Aired.String
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s [%s]\n", i+1, title, info))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostDailyReleases checks broadcast days and DMs watchers about anime airing today.
|
||||||
|
// Intended to be called by the scheduler daily.
|
||||||
|
func (p *AnimePlugin) PostDailyReleases(roomID id.RoomID) {
|
||||||
|
todayKey := time.Now().UTC().Format("2006-01-02")
|
||||||
|
if db.JobCompleted("anime_releases", todayKey) {
|
||||||
|
slog.Info("anime: already sent daily releases", "date", todayKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success := false
|
||||||
|
defer func() {
|
||||||
|
if success {
|
||||||
|
db.MarkJobCompleted("anime_releases", todayKey)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
today := strings.ToLower(time.Now().Weekday().String())
|
||||||
|
// Jikan uses plural day names like "Mondays", "Tuesdays", etc.
|
||||||
|
todayPlural := today + "s"
|
||||||
|
|
||||||
|
// Get all unique MAL IDs from watchlists
|
||||||
|
rows, err := d.Query(`SELECT DISTINCT mal_id, title FROM anime_watchlist`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("anime: daily releases query", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type animeEntry struct {
|
||||||
|
malID int
|
||||||
|
title string
|
||||||
|
}
|
||||||
|
var watchedAnime []animeEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var e animeEntry
|
||||||
|
if err := rows.Scan(&e.malID, &e.title); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
watchedAnime = append(watchedAnime, e)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, entry := range watchedAnime {
|
||||||
|
// Check cache or fetch anime details
|
||||||
|
var animeData jikanAnime
|
||||||
|
var cached string
|
||||||
|
var cachedAt int64
|
||||||
|
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT data, cached_at FROM anime_cache WHERE mal_id = ?`, entry.malID,
|
||||||
|
).Scan(&cached, &cachedAt)
|
||||||
|
|
||||||
|
needsFetch := err != nil || time.Now().Unix()-cachedAt > 24*3600
|
||||||
|
|
||||||
|
if !needsFetch {
|
||||||
|
if json.Unmarshal([]byte(cached), &animeData) != nil {
|
||||||
|
needsFetch = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if needsFetch {
|
||||||
|
apiURL := fmt.Sprintf("https://api.jikan.moe/v4/anime/%d", entry.malID)
|
||||||
|
var resp struct {
|
||||||
|
Data jikanAnime `json:"data"`
|
||||||
|
}
|
||||||
|
if err := p.jikanGet(apiURL, &resp); err != nil {
|
||||||
|
slog.Error("anime: daily fetch", "mal_id", entry.malID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
animeData = resp.Data
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
data, _ := json.Marshal(animeData)
|
||||||
|
_, _ = d.Exec(
|
||||||
|
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(mal_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
entry.malID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this anime broadcasts today
|
||||||
|
broadcastDay := strings.ToLower(animeData.Broadcast.Day)
|
||||||
|
if broadcastDay != todayPlural && broadcastDay != today {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only notify for currently airing anime
|
||||||
|
if animeData.Status != "Currently Airing" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find watchers for this anime
|
||||||
|
wRows, err := d.Query(
|
||||||
|
`SELECT user_id FROM anime_watchlist WHERE mal_id = ?`, entry.malID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
displayTitle := animeData.Title
|
||||||
|
if animeData.TitleEng != "" {
|
||||||
|
displayTitle = animeData.TitleEng
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("New episode alert! %s airs today (%s %s %s).\n%s",
|
||||||
|
displayTitle, animeData.Broadcast.Day, animeData.Broadcast.Time,
|
||||||
|
animeData.Broadcast.TZ, animeData.URL)
|
||||||
|
|
||||||
|
for wRows.Next() {
|
||||||
|
var uid string
|
||||||
|
if err := wRows.Scan(&uid); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.SendDM(id.UserID(uid), msg); err != nil {
|
||||||
|
slog.Error("anime: DM watcher", "user", uid, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
success = true
|
||||||
|
slog.Info("anime: daily releases check completed")
|
||||||
|
}
|
||||||
386
internal/plugin/birthday.go
Normal file
386
internal/plugin/birthday.go
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// BirthdayPlugin manages user birthdays with reminders and announcements.
|
||||||
|
type BirthdayPlugin struct {
|
||||||
|
Base
|
||||||
|
xp *XPPlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBirthdayPlugin creates a new BirthdayPlugin.
|
||||||
|
func NewBirthdayPlugin(client *mautrix.Client, xp *XPPlugin) *BirthdayPlugin {
|
||||||
|
return &BirthdayPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
xp: xp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) Name() string { return "birthday" }
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "birthday", Description: "Set, show, or remove your birthday", Usage: "!birthday set <MM-DD> | !birthday set <MM-DD-YYYY> | !birthday show | !birthday remove", Category: "Personal"},
|
||||||
|
{Name: "birthdays", Description: "Show upcoming birthdays (next 30 days)", Usage: "!birthdays", Category: "Personal"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "birthdays") {
|
||||||
|
return p.handleUpcoming(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "birthday") {
|
||||||
|
return p.handleBirthday(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) handleBirthday(ctx MessageContext) error {
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "birthday"))
|
||||||
|
parts := strings.SplitN(args, " ", 2)
|
||||||
|
if len(parts) == 0 || parts[0] == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !birthday set <MM-DD> | !birthday show | !birthday remove")
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := strings.ToLower(parts[0])
|
||||||
|
switch sub {
|
||||||
|
case "set":
|
||||||
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !birthday set <MM-DD> or !birthday set <MM-DD-YYYY>")
|
||||||
|
}
|
||||||
|
return p.handleSet(ctx, strings.TrimSpace(parts[1]))
|
||||||
|
case "show":
|
||||||
|
return p.handleShow(ctx)
|
||||||
|
case "remove":
|
||||||
|
return p.handleRemove(ctx)
|
||||||
|
default:
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !birthday set <MM-DD> | !birthday show | !birthday remove")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) handleSet(ctx MessageContext, dateStr string) error {
|
||||||
|
month, day, year, err := parseBirthdayDate(dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid date format: %s. Use MM-DD or MM-DD-YYYY.", err.Error()))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate the date makes sense
|
||||||
|
if month < 1 || month > 12 || day < 1 || day > 31 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid date. Month must be 1-12 and day must be 1-31.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate day for the given month
|
||||||
|
if year > 0 {
|
||||||
|
t := time.Date(year, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||||
|
if t.Month() != time.Month(month) {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid date: %s %d doesn't have %d days.", time.Month(month), year, day))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO birthdays (user_id, month, day, year, timezone) VALUES (?, ?, ?, ?, 'UTC')
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET month = ?, day = ?, year = ?`,
|
||||||
|
string(ctx.Sender), month, day, year, month, day, year,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("birthday: set", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save your birthday.")
|
||||||
|
}
|
||||||
|
|
||||||
|
display := fmt.Sprintf("%s %d", time.Month(month), day)
|
||||||
|
if year > 0 {
|
||||||
|
display += fmt.Sprintf(", %d", year)
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Birthday set to %s!", display))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) handleShow(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
var month, day, year int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT month, day, year FROM birthdays WHERE user_id = ?`, string(ctx.Sender),
|
||||||
|
).Scan(&month, &day, &year)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You haven't set your birthday yet. Use !birthday set <MM-DD>.")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("birthday: show", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch your birthday.")
|
||||||
|
}
|
||||||
|
|
||||||
|
display := fmt.Sprintf("%s %d", time.Month(month), day)
|
||||||
|
if year > 0 {
|
||||||
|
display += fmt.Sprintf(", %d", year)
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Your birthday: %s", display))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) handleRemove(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
res, err := d.Exec(`DELETE FROM birthdays WHERE user_id = ?`, string(ctx.Sender))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("birthday: remove", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove your birthday.")
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You don't have a birthday set.")
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Birthday removed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BirthdayPlugin) handleUpcoming(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
rows, err := d.Query(`SELECT user_id, month, day, year FROM birthdays ORDER BY month, day`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("birthday: upcoming query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch upcoming birthdays.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
type bdayEntry struct {
|
||||||
|
UserID string
|
||||||
|
Month int
|
||||||
|
Day int
|
||||||
|
Year int
|
||||||
|
DaysTo int
|
||||||
|
}
|
||||||
|
|
||||||
|
var upcoming []bdayEntry
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var month, day, year int
|
||||||
|
if err := rows.Scan(&userID, &month, &day, &year); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
daysTo := daysUntilBirthday(now, month, day)
|
||||||
|
if daysTo <= 30 {
|
||||||
|
upcoming = append(upcoming, bdayEntry{
|
||||||
|
UserID: userID,
|
||||||
|
Month: month,
|
||||||
|
Day: day,
|
||||||
|
Year: year,
|
||||||
|
DaysTo: daysTo,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(upcoming) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No upcoming birthdays in the next 30 days.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by days until birthday
|
||||||
|
for i := 0; i < len(upcoming); i++ {
|
||||||
|
for j := i + 1; j < len(upcoming); j++ {
|
||||||
|
if upcoming[j].DaysTo < upcoming[i].DaysTo {
|
||||||
|
upcoming[i], upcoming[j] = upcoming[j], upcoming[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Upcoming Birthdays (next 30 days):\n")
|
||||||
|
for _, b := range upcoming {
|
||||||
|
display := fmt.Sprintf("%s %d", time.Month(b.Month), b.Day)
|
||||||
|
if b.Year > 0 {
|
||||||
|
age := now.Year() - b.Year
|
||||||
|
// Adjust if birthday hasn't happened yet this year
|
||||||
|
bdayThisYear := time.Date(now.Year(), time.Month(b.Month), b.Day, 0, 0, 0, 0, time.UTC)
|
||||||
|
if now.Before(bdayThisYear) {
|
||||||
|
age--
|
||||||
|
}
|
||||||
|
display += fmt.Sprintf(" (turning %d)", age+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
when := "TODAY!"
|
||||||
|
if b.DaysTo == 1 {
|
||||||
|
when = "tomorrow"
|
||||||
|
} else if b.DaysTo > 1 {
|
||||||
|
when = fmt.Sprintf("in %d days", b.DaysTo)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf(" - %s: %s — %s\n", b.UserID, display, when))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// CheckAndPost checks for birthdays today and posts announcements.
|
||||||
|
func (p *BirthdayPlugin) CheckAndPost(roomID id.RoomID) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
month := int(now.Month())
|
||||||
|
day := now.Day()
|
||||||
|
year := now.Year()
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Collect all birthday matches first to avoid nested queries on a single SQLite connection
|
||||||
|
type bdayMatch struct {
|
||||||
|
userID string
|
||||||
|
birthYear int
|
||||||
|
}
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT user_id, year FROM birthdays WHERE month = ? AND day = ?`, month, day,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("birthday: check query: %w", err)
|
||||||
|
}
|
||||||
|
var matches []bdayMatch
|
||||||
|
for rows.Next() {
|
||||||
|
var m bdayMatch
|
||||||
|
if err := rows.Scan(&m.userID, &m.birthYear); err != nil {
|
||||||
|
slog.Error("birthday: scan", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
matches = append(matches, m)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return fmt.Errorf("birthday: rows iteration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, m := range matches {
|
||||||
|
// Check if already fired this year
|
||||||
|
var fired int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT 1 FROM birthday_fired WHERE user_id = ? AND year = ?`, m.userID, year,
|
||||||
|
).Scan(&fired)
|
||||||
|
if err == nil {
|
||||||
|
continue // Already fired this year
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build announcement
|
||||||
|
var ageStr string
|
||||||
|
if m.birthYear > 0 {
|
||||||
|
age := year - m.birthYear
|
||||||
|
ageStr = fmt.Sprintf(" They're turning %d!", age)
|
||||||
|
}
|
||||||
|
|
||||||
|
announcement := fmt.Sprintf("Happy Birthday to %s!%s Have an amazing day!", m.userID, ageStr)
|
||||||
|
if err := p.SendMessage(roomID, announcement); err != nil {
|
||||||
|
slog.Error("birthday: send announcement", "user", m.userID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send DM to the birthday person
|
||||||
|
dmMsg := fmt.Sprintf("Happy Birthday! The community wishes you a wonderful day!%s You've been granted 100 bonus XP as a birthday gift!", ageStr)
|
||||||
|
if err := p.SendDM(id.UserID(m.userID), dmMsg); err != nil {
|
||||||
|
slog.Error("birthday: send DM", "user", m.userID, "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant 100 XP
|
||||||
|
if p.xp != nil {
|
||||||
|
p.xp.GrantXP(id.UserID(m.userID), 100, "birthday")
|
||||||
|
} else {
|
||||||
|
p.grantBirthdayXP(id.UserID(m.userID), 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark as fired
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO birthday_fired (user_id, year) VALUES (?, ?)
|
||||||
|
ON CONFLICT(user_id, year) DO NOTHING`,
|
||||||
|
m.userID, year,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("birthday: mark fired", "user", m.userID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantBirthdayXP is a fallback that inserts XP directly via SQL if no XPPlugin is available.
|
||||||
|
func (p *BirthdayPlugin) grantBirthdayXP(userID id.UserID, amount int) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO users (user_id, xp, level, last_xp_at) VALUES (?, 0, 0, 0)
|
||||||
|
ON CONFLICT(user_id) DO NOTHING`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("birthday: ensure user", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(
|
||||||
|
`UPDATE users SET xp = xp + ?, last_xp_at = ? WHERE user_id = ?`,
|
||||||
|
amount, time.Now().UTC().Unix(), string(userID))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("birthday: grant xp", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||||
|
string(userID), amount, "birthday")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("birthday: log xp", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseBirthdayDate parses MM-DD or MM-DD-YYYY format.
|
||||||
|
func parseBirthdayDate(s string) (month, day, year int, err error) {
|
||||||
|
parts := strings.Split(s, "-")
|
||||||
|
switch len(parts) {
|
||||||
|
case 2:
|
||||||
|
// MM-DD
|
||||||
|
month, err = strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("invalid month: %s", parts[0])
|
||||||
|
}
|
||||||
|
day, err = strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("invalid day: %s", parts[1])
|
||||||
|
}
|
||||||
|
return month, day, 0, nil
|
||||||
|
case 3:
|
||||||
|
// MM-DD-YYYY
|
||||||
|
month, err = strconv.Atoi(parts[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("invalid month: %s", parts[0])
|
||||||
|
}
|
||||||
|
day, err = strconv.Atoi(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("invalid day: %s", parts[1])
|
||||||
|
}
|
||||||
|
year, err = strconv.Atoi(parts[2])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("invalid year: %s", parts[2])
|
||||||
|
}
|
||||||
|
return month, day, year, nil
|
||||||
|
default:
|
||||||
|
return 0, 0, 0, fmt.Errorf("use MM-DD or MM-DD-YYYY format")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// daysUntilBirthday calculates days from now until the next occurrence of the given month/day.
|
||||||
|
func daysUntilBirthday(now time.Time, month, day int) int {
|
||||||
|
thisYear := time.Date(now.Year(), time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||||
|
today := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
if thisYear.Before(today) {
|
||||||
|
// Birthday already passed this year, calculate for next year
|
||||||
|
thisYear = time.Date(now.Year()+1, time.Month(month), day, 0, 0, 0, 0, time.UTC)
|
||||||
|
}
|
||||||
|
|
||||||
|
return int(thisYear.Sub(today).Hours() / 24)
|
||||||
|
}
|
||||||
154
internal/plugin/botinfo.go
Normal file
154
internal/plugin/botinfo.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
botStartTime = time.Now().UTC()
|
||||||
|
botMsgCounter atomic.Int64
|
||||||
|
)
|
||||||
|
|
||||||
|
// IncrementMessageCount increments the global session message counter.
|
||||||
|
func IncrementMessageCount() {
|
||||||
|
botMsgCounter.Add(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BotInfoPlugin provides admin-only bot diagnostics.
|
||||||
|
type BotInfoPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBotInfoPlugin creates a new botinfo plugin.
|
||||||
|
func NewBotInfoPlugin(client *mautrix.Client) *BotInfoPlugin {
|
||||||
|
return &BotInfoPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BotInfoPlugin) Name() string { return "botinfo" }
|
||||||
|
|
||||||
|
func (p *BotInfoPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "botinfo", Description: "Show bot diagnostics (admin only)", Usage: "!botinfo", Category: "Admin", AdminOnly: true},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BotInfoPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *BotInfoPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *BotInfoPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.IsCommand(ctx.Body, "botinfo") {
|
||||||
|
// Passively count messages
|
||||||
|
IncrementMessageCount()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.IsAdmin(ctx.Sender) {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "This command is admin-only.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.handleBotInfo(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BotInfoPlugin) handleBotInfo(ctx MessageContext) error {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Bot Diagnostics\n\n")
|
||||||
|
|
||||||
|
// Uptime
|
||||||
|
uptime := time.Since(botStartTime)
|
||||||
|
days := int(uptime.Hours() / 24)
|
||||||
|
hours := int(uptime.Hours()) % 24
|
||||||
|
minutes := int(uptime.Minutes()) % 60
|
||||||
|
sb.WriteString(fmt.Sprintf("Uptime: %dd %dh %dm\n", days, hours, minutes))
|
||||||
|
|
||||||
|
// Session message count
|
||||||
|
sessionMsgs := botMsgCounter.Load()
|
||||||
|
sb.WriteString(fmt.Sprintf("Messages this session: %d\n", sessionMsgs))
|
||||||
|
|
||||||
|
// Total room messages from DB
|
||||||
|
d := db.Get()
|
||||||
|
var totalMsgs int
|
||||||
|
err := d.QueryRow(`SELECT COALESCE(SUM(total_messages), 0) FROM user_stats`).Scan(&totalMsgs)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("botinfo: total messages", "err", err)
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Total room messages (DB): %s\n", formatNumber(totalMsgs)))
|
||||||
|
|
||||||
|
// DB size
|
||||||
|
var pageCount, pageSize int
|
||||||
|
_ = d.QueryRow(`PRAGMA page_count`).Scan(&pageCount)
|
||||||
|
_ = d.QueryRow(`PRAGMA page_size`).Scan(&pageSize)
|
||||||
|
dbSizeBytes := int64(pageCount) * int64(pageSize)
|
||||||
|
dbSizeMB := float64(dbSizeBytes) / (1024 * 1024)
|
||||||
|
sb.WriteString(fmt.Sprintf("Database size: %.2f MB\n", dbSizeMB))
|
||||||
|
|
||||||
|
// Active reminders
|
||||||
|
var activeReminders int
|
||||||
|
_ = d.QueryRow(`SELECT COUNT(*) FROM reminders WHERE fired = 0`).Scan(&activeReminders)
|
||||||
|
sb.WriteString(fmt.Sprintf("Active reminders: %d\n", activeReminders))
|
||||||
|
|
||||||
|
// LLM status
|
||||||
|
ollamaHost := os.Getenv("OLLAMA_HOST")
|
||||||
|
if ollamaHost != "" {
|
||||||
|
llmStatus := p.checkLLMStatus(ollamaHost)
|
||||||
|
sb.WriteString(fmt.Sprintf("LLM status: %s\n", llmStatus))
|
||||||
|
} else {
|
||||||
|
sb.WriteString("LLM status: not configured\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *BotInfoPlugin) checkLLMStatus(ollamaHost string) string {
|
||||||
|
client := &http.Client{Timeout: 5 * time.Second}
|
||||||
|
apiURL := strings.TrimRight(ollamaHost, "/") + "/api/tags"
|
||||||
|
|
||||||
|
resp, err := client.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("offline (%s)", err.Error())
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return fmt.Sprintf("error (HTTP %d)", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "online (could not read response)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Models []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"models"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return "online (could not parse response)"
|
||||||
|
}
|
||||||
|
|
||||||
|
modelNames := make([]string, 0, len(result.Models))
|
||||||
|
for _, m := range result.Models {
|
||||||
|
modelNames = append(modelNames, m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(modelNames) == 0 {
|
||||||
|
return "online (no models loaded)"
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("online (%d models: %s)", len(modelNames), strings.Join(modelNames, ", "))
|
||||||
|
}
|
||||||
441
internal/plugin/concerts.go
Normal file
441
internal/plugin/concerts.go
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// bandsintownEvent represents an event from the Bandsintown API.
|
||||||
|
type bandsintownEvent struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
DateTime string `json:"datetime"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Venue struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
City string `json:"city"`
|
||||||
|
Region string `json:"region"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Latitude string `json:"latitude"`
|
||||||
|
Longitude string `json:"longitude"`
|
||||||
|
} `json:"venue"`
|
||||||
|
Lineup []string `json:"lineup"`
|
||||||
|
OnSaleAt string `json:"on_sale_datetime"`
|
||||||
|
Offers []struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
URL string `json:"url"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
} `json:"offers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ConcertsPlugin provides concert lookups via Bandsintown.
|
||||||
|
type ConcertsPlugin struct {
|
||||||
|
Base
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
mu sync.Mutex
|
||||||
|
cooldowns map[string]time.Time // keyed by userID+artist
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewConcertsPlugin creates a new ConcertsPlugin.
|
||||||
|
func NewConcertsPlugin(client *mautrix.Client) *ConcertsPlugin {
|
||||||
|
return &ConcertsPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
apiKey: os.Getenv("BANDSINTOWN_API_KEY"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
cooldowns: make(map[string]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) Name() string { return "concerts" }
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "concerts", Description: "Search upcoming concerts for an artist", Usage: "!concerts <artist>", Category: "Entertainment"},
|
||||||
|
{Name: "concerts watch", Description: "Watch an artist for concert updates", Usage: "!concerts watch <artist>", Category: "Entertainment"},
|
||||||
|
{Name: "concerts watching", Description: "List your watched artists", Usage: "!concerts watching", Category: "Entertainment"},
|
||||||
|
{Name: "concerts unwatch", Description: "Stop watching an artist", Usage: "!concerts unwatch <artist>", Category: "Entertainment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.IsCommand(ctx.Body, "concerts") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := p.GetArgs(ctx.Body, "concerts")
|
||||||
|
if args == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts <artist> | !concerts watch|watching|unwatch <artist>")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(args, " ", 2)
|
||||||
|
sub := strings.ToLower(parts[0])
|
||||||
|
|
||||||
|
switch sub {
|
||||||
|
case "watch":
|
||||||
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts watch <artist>")
|
||||||
|
}
|
||||||
|
return p.handleWatch(ctx, strings.TrimSpace(parts[1]))
|
||||||
|
case "watching":
|
||||||
|
return p.handleWatching(ctx)
|
||||||
|
case "unwatch":
|
||||||
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !concerts unwatch <artist>")
|
||||||
|
}
|
||||||
|
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
||||||
|
default:
|
||||||
|
return p.handleSearch(ctx, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) handleSearch(ctx MessageContext, artist string) error {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Concert lookups are not configured (missing API key).")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit check
|
||||||
|
cooldownKey := string(ctx.Sender) + ":" + strings.ToLower(artist)
|
||||||
|
p.mu.Lock()
|
||||||
|
if last, ok := p.cooldowns[cooldownKey]; ok && time.Since(last) < 30*time.Second {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Please wait a moment before searching for this artist again.")
|
||||||
|
}
|
||||||
|
p.cooldowns[cooldownKey] = time.Now()
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
events, err := p.fetchEvents(artist)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("concerts: fetch failed", "artist", artist, "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch concert data.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No upcoming concerts found for %s.", artist))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show up to 5 events
|
||||||
|
limit := 5
|
||||||
|
if len(events) < limit {
|
||||||
|
limit = len(events)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Upcoming concerts for %s:\n\n", artist))
|
||||||
|
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
ev := events[i]
|
||||||
|
dateStr := p.formatEventDate(ev.DateTime)
|
||||||
|
location := p.formatLocation(ev.Venue.City, ev.Venue.Region, ev.Venue.Country)
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s\n", i+1, dateStr))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Venue: %s\n", ev.Venue.Name))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Location: %s\n", location))
|
||||||
|
if ev.URL != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" Tickets: %s\n", ev.URL))
|
||||||
|
}
|
||||||
|
if i < limit-1 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) > limit {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n...and %d more events.", len(events)-limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) fetchEvents(artist string) ([]bandsintownEvent, error) {
|
||||||
|
d := db.Get()
|
||||||
|
artistKey := strings.ToLower(artist)
|
||||||
|
|
||||||
|
// Check cache (6h TTL)
|
||||||
|
var cached string
|
||||||
|
var cachedAt int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT data, cached_at FROM concerts_cache WHERE artist = ?`, artistKey,
|
||||||
|
).Scan(&cached, &cachedAt)
|
||||||
|
if err == nil && time.Now().Unix()-cachedAt < 6*3600 {
|
||||||
|
var events []bandsintownEvent
|
||||||
|
if json.Unmarshal([]byte(cached), &events) == nil {
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from API
|
||||||
|
encoded := url.PathEscape(artist)
|
||||||
|
apiURL := fmt.Sprintf("https://rest.bandsintown.com/artists/%s/events?app_id=%s&date=upcoming",
|
||||||
|
encoded, p.apiKey)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("bandsintown returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var events []bandsintownEvent
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&events); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
data, _ := json.Marshal(events)
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO concerts_cache (artist, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(artist) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
artistKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("concerts: cache write", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return events, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) formatEventDate(dateStr string) string {
|
||||||
|
layouts := []string{
|
||||||
|
"2006-01-02T15:04:05",
|
||||||
|
time.RFC3339,
|
||||||
|
"2006-01-02",
|
||||||
|
}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
if t, err := time.Parse(layout, dateStr); err == nil {
|
||||||
|
return t.Format("Mon, Jan 2, 2006 3:04 PM")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dateStr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) formatLocation(city, region, country string) string {
|
||||||
|
parts := []string{}
|
||||||
|
if city != "" {
|
||||||
|
parts = append(parts, city)
|
||||||
|
}
|
||||||
|
if region != "" {
|
||||||
|
parts = append(parts, region)
|
||||||
|
}
|
||||||
|
if country != "" {
|
||||||
|
parts = append(parts, country)
|
||||||
|
}
|
||||||
|
return strings.Join(parts, ", ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) handleWatch(ctx MessageContext, artist string) error {
|
||||||
|
d := db.Get()
|
||||||
|
artistKey := strings.ToLower(artist)
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO concert_watchlist (user_id, artist, room_id) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, artist) DO NOTHING`,
|
||||||
|
string(ctx.Sender), artistKey, string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("concerts: watch add", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add artist to watchlist.")
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Now watching %s for concert updates.", artist))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) handleWatching(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT artist FROM concert_watchlist WHERE user_id = ? ORDER BY artist`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("concerts: watching list", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var artists []string
|
||||||
|
for rows.Next() {
|
||||||
|
var a string
|
||||||
|
if err := rows.Scan(&a); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
artists = append(artists, a)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(artists) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not watching any artists. Use !concerts watch <artist> to start.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Your Concert Watchlist:\n")
|
||||||
|
for _, a := range artists {
|
||||||
|
sb.WriteString(fmt.Sprintf(" - %s\n", a))
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ConcertsPlugin) handleUnwatch(ctx MessageContext, artist string) error {
|
||||||
|
d := db.Get()
|
||||||
|
artistKey := strings.ToLower(artist)
|
||||||
|
res, err := d.Exec(
|
||||||
|
`DELETE FROM concert_watchlist WHERE user_id = ? AND artist = ?`,
|
||||||
|
string(ctx.Sender), artistKey,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("concerts: unwatch", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove artist from watchlist.")
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is not in your watchlist.", artist))
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Stopped watching %s.", artist))
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostWeeklyDigest sends a weekly concert digest to a room and DMs watchers about upcoming shows.
|
||||||
|
// Intended to be called by the scheduler on Sundays.
|
||||||
|
func (p *ConcertsPlugin) PostWeeklyDigest(roomID id.RoomID) {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
slog.Warn("concerts: skipping weekly digest, no API key configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
year, week := time.Now().UTC().ISOWeek()
|
||||||
|
weekKey := fmt.Sprintf("%d-W%02d", year, week)
|
||||||
|
if db.JobCompleted("concert_digest", weekKey) {
|
||||||
|
slog.Info("concerts: already sent digest this week", "week", weekKey)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Mark completed at the end only if we succeed (not deferred)
|
||||||
|
success := false
|
||||||
|
defer func() {
|
||||||
|
if success {
|
||||||
|
db.MarkJobCompleted("concert_digest", weekKey)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Get all unique watched artists
|
||||||
|
rows, err := d.Query(`SELECT DISTINCT artist FROM concert_watchlist`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("concerts: weekly digest query", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type watcherInfo struct {
|
||||||
|
userID id.UserID
|
||||||
|
roomID id.RoomID
|
||||||
|
}
|
||||||
|
|
||||||
|
artistWatchers := make(map[string][]watcherInfo)
|
||||||
|
var allArtists []string
|
||||||
|
|
||||||
|
// First collect all artists
|
||||||
|
for rows.Next() {
|
||||||
|
var artist string
|
||||||
|
if err := rows.Scan(&artist); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
allArtists = append(allArtists, artist)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// Get watchers per artist
|
||||||
|
for _, artist := range allArtists {
|
||||||
|
wRows, err := d.Query(
|
||||||
|
`SELECT user_id, room_id FROM concert_watchlist WHERE artist = ?`, artist,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for wRows.Next() {
|
||||||
|
var uid, rid string
|
||||||
|
if err := wRows.Scan(&uid, &rid); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
artistWatchers[artist] = append(artistWatchers[artist], watcherInfo{
|
||||||
|
userID: id.UserID(uid),
|
||||||
|
roomID: id.RoomID(rid),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
wRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// For each artist, fetch events and notify watchers
|
||||||
|
for artist, watchers := range artistWatchers {
|
||||||
|
events, err := p.fetchEvents(artist)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("concerts: weekly digest fetch", "artist", artist, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(events) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter to events in the next 2 weeks
|
||||||
|
now := time.Now()
|
||||||
|
twoWeeks := now.Add(14 * 24 * time.Hour)
|
||||||
|
var upcoming []bandsintownEvent
|
||||||
|
for _, ev := range events {
|
||||||
|
t, err := time.Parse("2006-01-02T15:04:05", ev.DateTime)
|
||||||
|
if err != nil {
|
||||||
|
t, err = time.Parse(time.RFC3339, ev.DateTime)
|
||||||
|
}
|
||||||
|
if err == nil && t.After(now) && t.Before(twoWeeks) {
|
||||||
|
upcoming = append(upcoming, ev)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(upcoming) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build message
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Upcoming shows for %s (next 2 weeks):\n\n", artist))
|
||||||
|
for i, ev := range upcoming {
|
||||||
|
if i >= 5 {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n...and %d more.", len(upcoming)-5))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dateStr := p.formatEventDate(ev.DateTime)
|
||||||
|
location := p.formatLocation(ev.Venue.City, ev.Venue.Region, ev.Venue.Country)
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s @ %s (%s)\n", dateStr, ev.Venue.Name, location))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := sb.String()
|
||||||
|
for _, w := range watchers {
|
||||||
|
if err := p.SendDM(w.userID, msg); err != nil {
|
||||||
|
slog.Error("concerts: DM watcher", "user", w.userID, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
success = true
|
||||||
|
slog.Info("concerts: weekly digest completed")
|
||||||
|
}
|
||||||
272
internal/plugin/countdown.go
Normal file
272
internal/plugin/countdown.go
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
var countdownLabelRe = regexp.MustCompile(`"([^"]+)"\s+(\d{4}-\d{2}-\d{2})`)
|
||||||
|
|
||||||
|
// CountdownPlugin manages countdown timers to specific dates.
|
||||||
|
type CountdownPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewCountdownPlugin creates a new countdown plugin.
|
||||||
|
func NewCountdownPlugin(client *mautrix.Client) *CountdownPlugin {
|
||||||
|
return &CountdownPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) Name() string { return "countdown" }
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "countdown", Description: "Manage countdowns to dates", Usage: "!countdown [add/private/mine/remove/id]", Category: "Personal"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) Init() error {
|
||||||
|
// Auto-complete old countdowns
|
||||||
|
p.completeOldCountdowns()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.IsCommand(ctx.Body, "countdown") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "countdown"))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(strings.ToLower(args), "add "):
|
||||||
|
return p.handleAdd(ctx, args[4:], true)
|
||||||
|
case strings.HasPrefix(strings.ToLower(args), "private "):
|
||||||
|
return p.handleAdd(ctx, args[8:], false)
|
||||||
|
case strings.ToLower(args) == "mine":
|
||||||
|
return p.handleMine(ctx)
|
||||||
|
case strings.HasPrefix(strings.ToLower(args), "remove "):
|
||||||
|
return p.handleRemove(ctx, strings.TrimSpace(args[7:]))
|
||||||
|
case args == "":
|
||||||
|
return p.handleList(ctx)
|
||||||
|
default:
|
||||||
|
// Try to parse as an ID
|
||||||
|
if _, err := strconv.Atoi(args); err == nil {
|
||||||
|
return p.handleShow(ctx, args)
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Usage: !countdown add \"<label>\" <YYYY-MM-DD> | private \"<label>\" <YYYY-MM-DD> | mine | remove <id> | [id]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) handleAdd(ctx MessageContext, input string, public bool) error {
|
||||||
|
matches := countdownLabelRe.FindStringSubmatch(input)
|
||||||
|
if matches == nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Usage: !countdown add \"<label>\" <YYYY-MM-DD>")
|
||||||
|
}
|
||||||
|
|
||||||
|
label := matches[1]
|
||||||
|
dateStr := matches[2]
|
||||||
|
|
||||||
|
_, err := time.Parse("2006-01-02", dateStr)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Invalid date format. Use YYYY-MM-DD.")
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
isPublic := 1
|
||||||
|
if !public {
|
||||||
|
isPublic = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO countdowns (user_id, room_id, label, target_date, public) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
string(ctx.Sender), string(ctx.RoomID), label, dateStr, isPublic,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("countdown: add", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add countdown.")
|
||||||
|
}
|
||||||
|
|
||||||
|
visibility := "public"
|
||||||
|
if !public {
|
||||||
|
visibility = "private"
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Countdown added (%s): \"%s\" on %s", visibility, label, dateStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) handleMine(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT id, label, target_date, public FROM countdowns
|
||||||
|
WHERE user_id = ? AND completed = 0
|
||||||
|
ORDER BY target_date ASC`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("countdown: mine", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load your countdowns.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Your countdowns:\n\n")
|
||||||
|
count := 0
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var cdID int
|
||||||
|
var label, targetDate string
|
||||||
|
var public int
|
||||||
|
if err := rows.Scan(&cdID, &label, &targetDate, &public); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, _ := time.Parse("2006-01-02", targetDate)
|
||||||
|
days := int(t.Sub(now).Hours() / 24)
|
||||||
|
vis := ""
|
||||||
|
if public == 0 {
|
||||||
|
vis = " (private)"
|
||||||
|
}
|
||||||
|
status := fmt.Sprintf("%d days", days)
|
||||||
|
if days < 0 {
|
||||||
|
status = fmt.Sprintf("%d days ago", -days)
|
||||||
|
} else if days == 0 {
|
||||||
|
status = "TODAY!"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" [%d] %s — %s (%s)%s\n", cdID, label, targetDate, status, vis))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You have no active countdowns.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) handleRemove(ctx MessageContext, idStr string) error {
|
||||||
|
d := db.Get()
|
||||||
|
result, err := d.Exec(
|
||||||
|
`DELETE FROM countdowns WHERE id = ? AND user_id = ?`,
|
||||||
|
idStr, string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("countdown: remove", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove countdown.")
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown not found or not yours.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown removed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) handleList(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT id, label, target_date, user_id FROM countdowns
|
||||||
|
WHERE public = 1 AND completed = 0
|
||||||
|
ORDER BY target_date ASC LIMIT 20`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("countdown: list", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load countdowns.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Active countdowns:\n\n")
|
||||||
|
count := 0
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var cdID int
|
||||||
|
var label, targetDate, userID string
|
||||||
|
if err := rows.Scan(&cdID, &label, &targetDate, &userID); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
t, _ := time.Parse("2006-01-02", targetDate)
|
||||||
|
days := int(t.Sub(now).Hours() / 24)
|
||||||
|
status := fmt.Sprintf("%d days", days)
|
||||||
|
if days < 0 {
|
||||||
|
status = fmt.Sprintf("%d days ago", -days)
|
||||||
|
} else if days == 0 {
|
||||||
|
status = "TODAY!"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" [%d] %s — %s (%s) by %s\n", cdID, label, targetDate, status, userID))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No active countdowns. Add one with !countdown add \"<label>\" <date>")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) handleShow(ctx MessageContext, idStr string) error {
|
||||||
|
d := db.Get()
|
||||||
|
var label, targetDate, userID string
|
||||||
|
var public int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT label, target_date, user_id, public FROM countdowns WHERE id = ? AND completed = 0`,
|
||||||
|
idStr,
|
||||||
|
).Scan(&label, &targetDate, &userID, &public)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Private countdowns only visible to owner
|
||||||
|
if public == 0 && userID != string(ctx.Sender) {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Countdown not found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
t, _ := time.Parse("2006-01-02", targetDate)
|
||||||
|
now := time.Now().UTC()
|
||||||
|
diff := t.Sub(now)
|
||||||
|
|
||||||
|
var status string
|
||||||
|
if diff < 0 {
|
||||||
|
absDays := int(-diff.Hours()) / 24
|
||||||
|
status = fmt.Sprintf("%d days ago", absDays)
|
||||||
|
} else {
|
||||||
|
days := int(diff.Hours()) / 24
|
||||||
|
hours := int(diff.Hours()) % 24
|
||||||
|
if days == 0 {
|
||||||
|
status = fmt.Sprintf("%d hours remaining!", hours)
|
||||||
|
} else {
|
||||||
|
status = fmt.Sprintf("%d days and %d hours", days, hours)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("\"%s\" — %s\n%s (by %s)", label, targetDate, status, userID)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *CountdownPlugin) completeOldCountdowns() {
|
||||||
|
d := db.Get()
|
||||||
|
cutoff := time.Now().UTC().AddDate(0, 0, -7).Format("2006-01-02")
|
||||||
|
_, err := d.Exec(
|
||||||
|
`UPDATE countdowns SET completed = 1 WHERE target_date < ? AND completed = 0`,
|
||||||
|
cutoff,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("countdown: auto-complete", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
581
internal/plugin/fun.go
Normal file
581
internal/plugin/fun.go
Normal file
@@ -0,0 +1,581 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// cityTimezones maps common city names to IANA timezone strings.
|
||||||
|
var cityTimezones = map[string]string{
|
||||||
|
"new york": "America/New_York",
|
||||||
|
"nyc": "America/New_York",
|
||||||
|
"los angeles": "America/Los_Angeles",
|
||||||
|
"la": "America/Los_Angeles",
|
||||||
|
"chicago": "America/Chicago",
|
||||||
|
"denver": "America/Denver",
|
||||||
|
"london": "Europe/London",
|
||||||
|
"paris": "Europe/Paris",
|
||||||
|
"berlin": "Europe/Berlin",
|
||||||
|
"tokyo": "Asia/Tokyo",
|
||||||
|
"sydney": "Australia/Sydney",
|
||||||
|
"dubai": "Asia/Dubai",
|
||||||
|
"moscow": "Europe/Moscow",
|
||||||
|
"mumbai": "Asia/Kolkata",
|
||||||
|
"beijing": "Asia/Shanghai",
|
||||||
|
"shanghai": "Asia/Shanghai",
|
||||||
|
"singapore": "Asia/Singapore",
|
||||||
|
"hong kong": "Asia/Hong_Kong",
|
||||||
|
"seoul": "Asia/Seoul",
|
||||||
|
"toronto": "America/Toronto",
|
||||||
|
"vancouver": "America/Vancouver",
|
||||||
|
"sao paulo": "America/Sao_Paulo",
|
||||||
|
"mexico city": "America/Mexico_City",
|
||||||
|
"cairo": "Africa/Cairo",
|
||||||
|
"johannesburg": "Africa/Johannesburg",
|
||||||
|
"auckland": "Pacific/Auckland",
|
||||||
|
"honolulu": "Pacific/Honolulu",
|
||||||
|
"hawaii": "Pacific/Honolulu",
|
||||||
|
"anchorage": "America/Anchorage",
|
||||||
|
"amsterdam": "Europe/Amsterdam",
|
||||||
|
"rome": "Europe/Rome",
|
||||||
|
"madrid": "Europe/Madrid",
|
||||||
|
"lisbon": "Europe/Lisbon",
|
||||||
|
"bangkok": "Asia/Bangkok",
|
||||||
|
"jakarta": "Asia/Jakarta",
|
||||||
|
"manila": "Asia/Manila",
|
||||||
|
"taipei": "Asia/Taipei",
|
||||||
|
"utc": "UTC",
|
||||||
|
"gmt": "UTC",
|
||||||
|
}
|
||||||
|
|
||||||
|
var eightBallResponses = []string{
|
||||||
|
"It is certain.", "It is decidedly so.", "Without a doubt.",
|
||||||
|
"Yes, definitely.", "You may rely on it.", "As I see it, yes.",
|
||||||
|
"Most likely.", "Outlook good.", "Yes.", "Signs point to yes.",
|
||||||
|
"Reply hazy, try again.", "Ask again later.", "Better not tell you now.",
|
||||||
|
"Cannot predict now.", "Concentrate and ask again.",
|
||||||
|
"Don't count on it.", "My reply is no.", "My sources say no.",
|
||||||
|
"Outlook not so good.", "Very doubtful.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var twinbeeFacts = []string{
|
||||||
|
"TwinBee was first released in 1985 by Konami for arcades in Japan.",
|
||||||
|
"The TwinBee series is known as a 'cute 'em up' — a cute-themed shoot 'em up.",
|
||||||
|
"TwinBee's main characters are sentient, bell-powered fighter ships.",
|
||||||
|
"The bells in TwinBee change color when shot, granting different power-ups.",
|
||||||
|
"WinBee, the pink ship, is piloted by Pastel in the later games.",
|
||||||
|
"TwinBee Rainbow Bell Adventure is a rare platformer spinoff for the SNES.",
|
||||||
|
"GwinBee is the green third ship, piloted by Mint in TwinBee Yahho!",
|
||||||
|
"The TwinBee anime, 'TwinBee Paradise', aired as a radio drama in Japan.",
|
||||||
|
"Detana!! TwinBee is considered one of the best entries and was a PC Engine hit.",
|
||||||
|
"TwinBee Yahho! (1995) was the last major arcade release in the series.",
|
||||||
|
"Pop'n TwinBee for the SNES featured a 2-player co-op mode with combined attacks.",
|
||||||
|
"In TwinBee games, you punch clouds to release bells for power-ups.",
|
||||||
|
"The TwinBee series inspired parts of Konami's Parodius games.",
|
||||||
|
"Light and Pastel, the pilots of TwinBee and WinBee, are childhood friends.",
|
||||||
|
"Dr. Cinnamon is the inventor who created the TwinBee ships.",
|
||||||
|
"TwinBee 3: Poko Poko Daimaou featured an overworld map, mixing RPG and shooter elements.",
|
||||||
|
}
|
||||||
|
|
||||||
|
var diceRe = regexp.MustCompile(`(?i)^(\d+)?d(\d+)([+-]\d+)?$`)
|
||||||
|
|
||||||
|
// FunPlugin provides various fun and utility commands.
|
||||||
|
type FunPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFunPlugin creates a new FunPlugin.
|
||||||
|
func NewFunPlugin(client *mautrix.Client) *FunPlugin {
|
||||||
|
return &FunPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) Name() string { return "fun" }
|
||||||
|
|
||||||
|
func (p *FunPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "roll", Description: "Roll dice", Usage: "!roll [NdM+X]", Category: "Fun & Games"},
|
||||||
|
{Name: "8ball", Description: "Magic 8-ball", Usage: "!8ball <question>", Category: "Fun & Games"},
|
||||||
|
{Name: "coin", Description: "Flip a coin", Usage: "!coin", Category: "Fun & Games"},
|
||||||
|
{Name: "time", Description: "World clock", Usage: "!time [city]", Category: "Lookup & Reference"},
|
||||||
|
{Name: "hltb", Description: "HowLongToBeat lookup", Usage: "!hltb <game>", Category: "Lookup & Reference"},
|
||||||
|
{Name: "twinbee", Description: "Random Twinbee lore/trivia", Usage: "!twinbee", Category: "Fun & Games"},
|
||||||
|
{Name: "poll", Description: "Create a reaction poll", Usage: "!poll <question> | <option1> | <option2> ...", Category: "Fun & Games"},
|
||||||
|
{Name: "weather", Description: "Weather lookup", Usage: "!weather <location>", Category: "Lookup & Reference"},
|
||||||
|
{Name: "dadjoke", Description: "Random dad joke", Usage: "!dadjoke", Category: "Fun & Games"},
|
||||||
|
{Name: "randomwiki", Description: "Random Wikipedia article", Usage: "!randomwiki", Category: "Fun & Games"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *FunPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *FunPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
switch {
|
||||||
|
case p.IsCommand(ctx.Body, "roll"):
|
||||||
|
return p.handleRoll(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "8ball"):
|
||||||
|
return p.handleEightBall(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "coin"):
|
||||||
|
return p.handleCoin(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "time"):
|
||||||
|
return p.handleTime(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "hltb"):
|
||||||
|
return p.handleHLTB(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "twinbee"):
|
||||||
|
return p.handleTwinbee(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "poll"):
|
||||||
|
return p.handlePoll(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "weather"):
|
||||||
|
return p.handleWeather(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "dadjoke"):
|
||||||
|
return p.handleDadJoke(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "randomwiki"):
|
||||||
|
return p.handleRandomWiki(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handleRoll(ctx MessageContext) error {
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "roll"))
|
||||||
|
|
||||||
|
numDice := 1
|
||||||
|
sides := 6
|
||||||
|
modifier := 0
|
||||||
|
|
||||||
|
if args != "" {
|
||||||
|
matches := diceRe.FindStringSubmatch(args)
|
||||||
|
if matches == nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Invalid dice format. Use NdM+X (e.g., 2d6+3, d20, 4d8-1)")
|
||||||
|
}
|
||||||
|
if matches[1] != "" {
|
||||||
|
numDice, _ = strconv.Atoi(matches[1])
|
||||||
|
}
|
||||||
|
sides, _ = strconv.Atoi(matches[2])
|
||||||
|
if matches[3] != "" {
|
||||||
|
modifier, _ = strconv.Atoi(matches[3])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if numDice < 1 || numDice > 100 {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Number of dice must be between 1 and 100.")
|
||||||
|
}
|
||||||
|
if sides < 2 || sides > 1000 {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Number of sides must be between 2 and 1000.")
|
||||||
|
}
|
||||||
|
|
||||||
|
rolls := make([]int, numDice)
|
||||||
|
total := 0
|
||||||
|
for i := range numDice {
|
||||||
|
rolls[i] = rand.IntN(sides) + 1
|
||||||
|
total += rolls[i]
|
||||||
|
}
|
||||||
|
total += modifier
|
||||||
|
|
||||||
|
var result string
|
||||||
|
if numDice == 1 && modifier == 0 {
|
||||||
|
result = fmt.Sprintf("🎲 You rolled a **%d** (d%d)", total, sides)
|
||||||
|
} else {
|
||||||
|
rollStrs := make([]string, len(rolls))
|
||||||
|
for i, r := range rolls {
|
||||||
|
rollStrs[i] = strconv.Itoa(r)
|
||||||
|
}
|
||||||
|
modStr := ""
|
||||||
|
if modifier > 0 {
|
||||||
|
modStr = fmt.Sprintf("+%d", modifier)
|
||||||
|
} else if modifier < 0 {
|
||||||
|
modStr = fmt.Sprintf("%d", modifier)
|
||||||
|
}
|
||||||
|
result = fmt.Sprintf("🎲 Rolled %dd%d%s: [%s] = **%d**", numDice, sides, modStr, strings.Join(rollStrs, ", "), total)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handleEightBall(ctx MessageContext) error {
|
||||||
|
question := strings.TrimSpace(p.GetArgs(ctx.Body, "8ball"))
|
||||||
|
if question == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "🎱 You need to ask a question!")
|
||||||
|
}
|
||||||
|
|
||||||
|
response := eightBallResponses[rand.IntN(len(eightBallResponses))]
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🎱 %s", response))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handleCoin(ctx MessageContext) error {
|
||||||
|
if rand.IntN(2) == 0 {
|
||||||
|
return p.SendMessage(ctx.RoomID, "🪙 **Heads!**")
|
||||||
|
}
|
||||||
|
return p.SendMessage(ctx.RoomID, "🪙 **Tails!**")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handleTime(ctx MessageContext) error {
|
||||||
|
args := strings.TrimSpace(strings.ToLower(p.GetArgs(ctx.Body, "time")))
|
||||||
|
if args == "" {
|
||||||
|
// Show a few major cities
|
||||||
|
cities := []string{"new york", "london", "tokyo", "sydney"}
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🕐 World Clock:\n")
|
||||||
|
for _, city := range cities {
|
||||||
|
tz, _ := time.LoadLocation(cityTimezones[city])
|
||||||
|
t := time.Now().In(tz)
|
||||||
|
sb.WriteString(fmt.Sprintf(" • %s: %s\n", titleCase(city), t.Format("Mon Jan 2 15:04 MST")))
|
||||||
|
}
|
||||||
|
sb.WriteString("\nUse !time <city> for a specific location.")
|
||||||
|
return p.SendMessage(ctx.RoomID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
tzName, ok := cityTimezones[args]
|
||||||
|
if !ok {
|
||||||
|
// Try loading it as a raw IANA zone
|
||||||
|
tzName = args
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := time.LoadLocation(tzName)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Unknown city or timezone: %s", args))
|
||||||
|
}
|
||||||
|
|
||||||
|
t := time.Now().In(loc)
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🕐 %s: **%s**", args, t.Format("Monday, January 2, 2006 3:04 PM MST")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handleHLTB(ctx MessageContext) error {
|
||||||
|
gameName := strings.TrimSpace(p.GetArgs(ctx.Body, "hltb"))
|
||||||
|
if gameName == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Usage: !hltb <game name>")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cache (24 hour TTL)
|
||||||
|
var cachedData string
|
||||||
|
var cachedAt int64
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT data, cached_at FROM hltb_cache WHERE game_name = ?`,
|
||||||
|
strings.ToLower(gameName),
|
||||||
|
).Scan(&cachedData, &cachedAt)
|
||||||
|
if err == nil && time.Now().Unix()-cachedAt < 86400 {
|
||||||
|
return p.SendMessage(ctx.RoomID, cachedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrape HLTB
|
||||||
|
result, err := scrapeHLTB(gameName)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("hltb: scrape failed", "err", err, "game", gameName)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to look up that game on HowLongToBeat.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("No results found for \"%s\" on HowLongToBeat.", gameName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache result
|
||||||
|
_, _ = db.Get().Exec(
|
||||||
|
`INSERT INTO hltb_cache (game_name, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(game_name) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
strings.ToLower(gameName), result, time.Now().Unix(), result, time.Now().Unix(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func scrapeHLTB(gameName string) (string, error) {
|
||||||
|
payload := fmt.Sprintf(`{"searchType":"games","searchTerms":["%s"],"searchPage":1,"size":1,"searchOptions":{"games":{"userId":0,"platform":"","sortCategory":"popular","rangeCategory":"main","rangeTime":{"min":null,"max":null},"gameplay":{"perspective":"","flow":"","genre":"","subGenre":""},"rangeYear":{"min":"","max":""},"modifier":""},"users":{"sortCategory":"postcount"},"lists":{"sortCategory":"follows"},"filter":"","sort":0,"randomizer":0}}`,
|
||||||
|
strings.ReplaceAll(gameName, `"`, `\"`))
|
||||||
|
|
||||||
|
req, err := http.NewRequest("POST", "https://howlongtobeat.com/api/search", strings.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36")
|
||||||
|
req.Header.Set("Referer", "https://howlongtobeat.com")
|
||||||
|
req.Header.Set("Origin", "https://howlongtobeat.com")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 15 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Data []struct {
|
||||||
|
GameName string `json:"game_name"`
|
||||||
|
CompMain float64 `json:"comp_main"`
|
||||||
|
CompPlus float64 `json:"comp_plus"`
|
||||||
|
CompAll float64 `json:"comp_100"`
|
||||||
|
ProfileDev string `json:"profile_dev"`
|
||||||
|
ReleaseWorld int `json:"release_world"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(result.Data) == 0 {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
game := result.Data[0]
|
||||||
|
|
||||||
|
formatTime := func(seconds float64) string {
|
||||||
|
if seconds <= 0 {
|
||||||
|
return "N/A"
|
||||||
|
}
|
||||||
|
hours := seconds / 3600.0
|
||||||
|
if hours < 1 {
|
||||||
|
return fmt.Sprintf("%.0f min", seconds/60.0)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%.1f hours", hours)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("🎮 **%s**\n", game.GameName))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Main Story: %s\n", formatTime(game.CompMain)))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Main + Extras: %s\n", formatTime(game.CompPlus)))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Completionist: %s\n", formatTime(game.CompAll)))
|
||||||
|
if game.ProfileDev != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" Developer: %s\n", game.ProfileDev))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handleTwinbee(ctx MessageContext) error {
|
||||||
|
fact := twinbeeFacts[rand.IntN(len(twinbeeFacts))]
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🐝 **Twinbee Trivia:** %s", fact))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handlePoll(ctx MessageContext) error {
|
||||||
|
args := p.GetArgs(ctx.Body, "poll")
|
||||||
|
if args == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Usage: !poll <question> | <option1> | <option2> ...")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(args, "|")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return p.SendMessage(ctx.RoomID, "A poll needs a question and at least 2 options, separated by |")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(parts) > 11 {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Maximum 10 options allowed.")
|
||||||
|
}
|
||||||
|
|
||||||
|
question := strings.TrimSpace(parts[0])
|
||||||
|
options := make([]string, 0, len(parts)-1)
|
||||||
|
for _, opt := range parts[1:] {
|
||||||
|
trimmed := strings.TrimSpace(opt)
|
||||||
|
if trimmed != "" {
|
||||||
|
options = append(options, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
numberEmojis := []string{"1\uFE0F\u20E3", "2\uFE0F\u20E3", "3\uFE0F\u20E3", "4\uFE0F\u20E3", "5\uFE0F\u20E3",
|
||||||
|
"6\uFE0F\u20E3", "7\uFE0F\u20E3", "8\uFE0F\u20E3", "9\uFE0F\u20E3", "\U0001F51F"}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("📊 **Poll: %s**\n\n", question))
|
||||||
|
for i, opt := range options {
|
||||||
|
if i < len(numberEmojis) {
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s\n", numberEmojis[i], opt))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\nReact with the number to vote!")
|
||||||
|
|
||||||
|
if err := p.SendMessage(ctx.RoomID, sb.String()); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handleWeather(ctx MessageContext) error {
|
||||||
|
location := strings.TrimSpace(p.GetArgs(ctx.Body, "weather"))
|
||||||
|
if location == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Usage: !weather <location>")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiKey := os.Getenv("OPENWEATHER_API_KEY")
|
||||||
|
if apiKey == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Weather service is not configured (missing API key).")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://api.openweathermap.org/data/2.5/weather?q=%s&appid=%s&units=metric",
|
||||||
|
url.QueryEscape(location), apiKey)
|
||||||
|
|
||||||
|
resp, err := http.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("weather: API request failed", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to fetch weather data.")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Location \"%s\" not found.", location))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to read weather data.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var weather struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Main struct {
|
||||||
|
Temp float64 `json:"temp"`
|
||||||
|
FeelsLike float64 `json:"feels_like"`
|
||||||
|
Humidity int `json:"humidity"`
|
||||||
|
TempMin float64 `json:"temp_min"`
|
||||||
|
TempMax float64 `json:"temp_max"`
|
||||||
|
} `json:"main"`
|
||||||
|
Weather []struct {
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"weather"`
|
||||||
|
Wind struct {
|
||||||
|
Speed float64 `json:"speed"`
|
||||||
|
} `json:"wind"`
|
||||||
|
Sys struct {
|
||||||
|
Country string `json:"country"`
|
||||||
|
} `json:"sys"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &weather); err != nil {
|
||||||
|
slog.Error("weather: parse response", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to parse weather data.")
|
||||||
|
}
|
||||||
|
|
||||||
|
desc := "N/A"
|
||||||
|
if len(weather.Weather) > 0 {
|
||||||
|
desc = weather.Weather[0].Description
|
||||||
|
}
|
||||||
|
|
||||||
|
tempF := weather.Main.Temp*9.0/5.0 + 32
|
||||||
|
feelsLikeF := weather.Main.FeelsLike*9.0/5.0 + 32
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("🌤️ **Weather in %s, %s**\n", weather.Name, weather.Sys.Country))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Condition: %s\n", titleCase(desc)))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Temperature: %.1f°C (%.1f°F)\n", weather.Main.Temp, tempF))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Feels Like: %.1f°C (%.1f°F)\n", weather.Main.FeelsLike, feelsLikeF))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Humidity: %d%%\n", weather.Main.Humidity))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Wind: %.1f m/s\n", weather.Wind.Speed))
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handleDadJoke(ctx MessageContext) error {
|
||||||
|
req, err := http.NewRequest("GET", "https://icanhazdadjoke.com/", nil)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to fetch a dad joke.")
|
||||||
|
}
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
req.Header.Set("User-Agent", "GogoBee Matrix Bot")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("dadjoke: request failed", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to fetch a dad joke.")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to read dad joke response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var joke struct {
|
||||||
|
Joke string `json:"joke"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &joke); err != nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to parse dad joke.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("😄 %s", joke.Joke))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *FunPlugin) handleRandomWiki(ctx MessageContext) error {
|
||||||
|
req, err := http.NewRequest("GET", "https://en.wikipedia.org/api/rest_v1/page/random/summary", nil)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to fetch a random article.")
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "GogoBee Matrix Bot")
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("randomwiki: request failed", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to fetch a random Wikipedia article.")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to read Wikipedia response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var article struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Extract string `json:"extract"`
|
||||||
|
ContentURLs struct {
|
||||||
|
Desktop struct {
|
||||||
|
Page string `json:"page"`
|
||||||
|
} `json:"desktop"`
|
||||||
|
} `json:"content_urls"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &article); err != nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to parse Wikipedia response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
extract := article.Extract
|
||||||
|
if len(extract) > 300 {
|
||||||
|
extract = extract[:300] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("📖 **%s**\n", article.Title))
|
||||||
|
sb.WriteString(extract)
|
||||||
|
if article.ContentURLs.Desktop.Page != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n🔗 %s", article.ContentURLs.Desktop.Page))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// titleCase capitalizes the first letter of each word (replacement for deprecated strings.Title).
|
||||||
|
func titleCase(s string) string {
|
||||||
|
prev := ' '
|
||||||
|
return strings.Map(func(r rune) rune {
|
||||||
|
if unicode.IsSpace(rune(prev)) || prev == ' ' {
|
||||||
|
prev = r
|
||||||
|
return unicode.ToTitle(r)
|
||||||
|
}
|
||||||
|
prev = r
|
||||||
|
return r
|
||||||
|
}, s)
|
||||||
|
}
|
||||||
430
internal/plugin/gaming.go
Normal file
430
internal/plugin/gaming.go
Normal file
@@ -0,0 +1,430 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// releaseListResponse is the top-level RAWG API response for game releases.
|
||||||
|
type releaseListResponse struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Results []releaseEntry `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// releaseEntry is a game entry from the RAWG releases API.
|
||||||
|
type releaseEntry struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Released string `json:"released"`
|
||||||
|
Rating float64 `json:"rating"`
|
||||||
|
Metacritic int `json:"metacritic"`
|
||||||
|
Platforms []struct {
|
||||||
|
Platform struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"platform"`
|
||||||
|
} `json:"platforms"`
|
||||||
|
Genres []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"genres"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GamingPlugin provides game release tracking using the RAWG API.
|
||||||
|
type GamingPlugin struct {
|
||||||
|
Base
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGamingPlugin creates a new GamingPlugin.
|
||||||
|
func NewGamingPlugin(client *mautrix.Client) *GamingPlugin {
|
||||||
|
return &GamingPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
apiKey: os.Getenv("RAWG_API_KEY"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) Name() string { return "gaming" }
|
||||||
|
|
||||||
|
func (p *GamingPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "releases", Description: "Show game releases", Usage: "!releases [month|search <query>]", Category: "Entertainment"},
|
||||||
|
{Name: "releasewatch", Description: "Manage your game release watchlist", Usage: "!releasewatch add|list|remove <game>", Category: "Entertainment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *GamingPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *GamingPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "releases") {
|
||||||
|
return p.handleReleases(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "releasewatch") {
|
||||||
|
return p.handleReleaseWatch(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostReleases posts this week's notable game releases to the given room.
|
||||||
|
func (p *GamingPlugin) PostReleases(roomID id.RoomID) error {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
slog.Warn("gaming: RAWG_API_KEY not set, skipping release post")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already posted this week
|
||||||
|
now := time.Now().UTC()
|
||||||
|
year, week := now.ISOWeek()
|
||||||
|
weekKey := fmt.Sprintf("%d-W%02d", year, week)
|
||||||
|
if db.JobCompleted("releases", weekKey) {
|
||||||
|
slog.Info("gaming: already posted releases this week", "week", weekKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
startDate := now.Format("2006-01-02")
|
||||||
|
endDate := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||||
|
|
||||||
|
games, err := p.fetchReleases(startDate, endDate, "week")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gaming: fetch releases: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(games) == 0 {
|
||||||
|
slog.Info("gaming: no notable releases this week")
|
||||||
|
db.MarkJobCompleted("releases", weekKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := p.formatReleases("This Week's Game Releases", games)
|
||||||
|
if err := p.SendMessage(roomID, msg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
db.MarkJobCompleted("releases", weekKey)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) handleReleases(ctx MessageContext) error {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Game release lookups are not configured (missing API key).")
|
||||||
|
}
|
||||||
|
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "releases"))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case args == "" || strings.ToLower(args) == "week":
|
||||||
|
return p.handleWeekReleases(ctx)
|
||||||
|
case strings.ToLower(args) == "month":
|
||||||
|
return p.handleMonthReleases(ctx)
|
||||||
|
case strings.HasPrefix(strings.ToLower(args), "search "):
|
||||||
|
query := strings.TrimSpace(args[7:])
|
||||||
|
return p.handleSearchReleases(ctx, query)
|
||||||
|
default:
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releases [month|search <query>]")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) handleWeekReleases(ctx MessageContext) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startDate := now.Format("2006-01-02")
|
||||||
|
endDate := now.AddDate(0, 0, 7).Format("2006-01-02")
|
||||||
|
|
||||||
|
games, err := p.fetchReleases(startDate, endDate, "week")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("gaming: fetch week releases", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch game releases.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(games) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No notable game releases this week.")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := p.formatReleases("This Week's Game Releases", games)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) handleMonthReleases(ctx MessageContext) error {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startDate := now.Format("2006-01-02")
|
||||||
|
endDate := now.AddDate(0, 1, 0).Format("2006-01-02")
|
||||||
|
|
||||||
|
games, err := p.fetchReleases(startDate, endDate, "month")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("gaming: fetch month releases", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch game releases.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(games) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No notable game releases this month.")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := p.formatReleases("This Month's Game Releases", games)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) handleSearchReleases(ctx MessageContext, query string) error {
|
||||||
|
if query == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releases search <query>")
|
||||||
|
}
|
||||||
|
|
||||||
|
games, err := p.searchUpcoming(query)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("gaming: search releases", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search game releases.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(games) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No upcoming releases found for \"%s\".", query))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := p.formatReleases(fmt.Sprintf("Search Results: \"%s\"", query), games)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) fetchReleases(startDate, endDate, cacheKey string) ([]releaseEntry, error) {
|
||||||
|
d := db.Get()
|
||||||
|
fullKey := fmt.Sprintf("releases_%s_%s_%s", cacheKey, startDate, endDate)
|
||||||
|
|
||||||
|
// Check cache (1 hour TTL)
|
||||||
|
var cached string
|
||||||
|
var cachedAt int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT data, cached_at FROM releases_cache WHERE cache_key = ?`, fullKey,
|
||||||
|
).Scan(&cached, &cachedAt)
|
||||||
|
if err == nil && time.Now().UTC().Unix()-cachedAt < 3600 {
|
||||||
|
var games []releaseEntry
|
||||||
|
if json.Unmarshal([]byte(cached), &games) == nil {
|
||||||
|
return games, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from RAWG
|
||||||
|
apiURL := fmt.Sprintf(
|
||||||
|
"https://api.rawg.io/api/games?key=%s&dates=%s,%s&ordering=-added&page_size=20",
|
||||||
|
p.apiKey, startDate, endDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("RAWG returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawgResp releaseListResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&rawgResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
games := rawgResp.Results
|
||||||
|
|
||||||
|
// Cache results
|
||||||
|
data, _ := json.Marshal(games)
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO releases_cache (cache_key, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(cache_key) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
fullKey, string(data), time.Now().UTC().Unix(), string(data), time.Now().UTC().Unix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("gaming: cache write", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return games, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) searchUpcoming(query string) ([]releaseEntry, error) {
|
||||||
|
now := time.Now().UTC()
|
||||||
|
startDate := now.Format("2006-01-02")
|
||||||
|
endDate := now.AddDate(1, 0, 0).Format("2006-01-02")
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf(
|
||||||
|
"https://api.rawg.io/api/games?key=%s&search=%s&dates=%s,%s&ordering=-added&page_size=10",
|
||||||
|
p.apiKey, url.QueryEscape(query), startDate, endDate,
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("RAWG search returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawgResp releaseListResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&rawgResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return rawgResp.Results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) formatReleases(title string, games []releaseEntry) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(title)
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
limit := 15
|
||||||
|
if len(games) < limit {
|
||||||
|
limit = len(games)
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
g := games[i]
|
||||||
|
sb.WriteString(fmt.Sprintf("\n- %s", g.Name))
|
||||||
|
if g.Released != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" (%s)", g.Released))
|
||||||
|
}
|
||||||
|
|
||||||
|
var details []string
|
||||||
|
if len(g.Platforms) > 0 {
|
||||||
|
var plats []string
|
||||||
|
for _, pl := range g.Platforms {
|
||||||
|
plats = append(plats, pl.Platform.Name)
|
||||||
|
}
|
||||||
|
if len(plats) > 4 {
|
||||||
|
plats = append(plats[:4], "...")
|
||||||
|
}
|
||||||
|
details = append(details, strings.Join(plats, ", "))
|
||||||
|
}
|
||||||
|
if len(g.Genres) > 0 {
|
||||||
|
var genres []string
|
||||||
|
for _, gn := range g.Genres {
|
||||||
|
genres = append(genres, gn.Name)
|
||||||
|
}
|
||||||
|
details = append(details, strings.Join(genres, ", "))
|
||||||
|
}
|
||||||
|
if g.Metacritic > 0 {
|
||||||
|
details = append(details, fmt.Sprintf("Metacritic: %d", g.Metacritic))
|
||||||
|
}
|
||||||
|
if len(details) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n %s", strings.Join(details, " | ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(games) > limit {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n\n...and %d more.", len(games)-limit))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) handleReleaseWatch(ctx MessageContext) error {
|
||||||
|
args := p.GetArgs(ctx.Body, "releasewatch")
|
||||||
|
parts := strings.SplitN(args, " ", 2)
|
||||||
|
if len(parts) == 0 || parts[0] == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releasewatch add|list|remove <game>")
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := strings.ToLower(parts[0])
|
||||||
|
switch sub {
|
||||||
|
case "add":
|
||||||
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releasewatch add <game>")
|
||||||
|
}
|
||||||
|
return p.watchlistAdd(ctx, strings.TrimSpace(parts[1]))
|
||||||
|
case "remove":
|
||||||
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releasewatch remove <game>")
|
||||||
|
}
|
||||||
|
return p.watchlistRemove(ctx, strings.TrimSpace(parts[1]))
|
||||||
|
case "list":
|
||||||
|
return p.watchlistList(ctx)
|
||||||
|
default:
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !releasewatch add|list|remove <game>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) watchlistAdd(ctx MessageContext, game string) error {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO release_watchlist (user_id, game_name, room_id) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, game_name) DO NOTHING`,
|
||||||
|
string(ctx.Sender), game, string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("gaming: watchlist add", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Added \"%s\" to your release watchlist.", game))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) watchlistRemove(ctx MessageContext, game string) error {
|
||||||
|
d := db.Get()
|
||||||
|
res, err := d.Exec(
|
||||||
|
`DELETE FROM release_watchlist WHERE user_id = ? AND game_name = ?`,
|
||||||
|
string(ctx.Sender), game,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("gaming: watchlist remove", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("\"%s\" is not in your watchlist.", game))
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed \"%s\" from your watchlist.", game))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *GamingPlugin) watchlistList(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT game_name FROM release_watchlist WHERE user_id = ? ORDER BY game_name`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("gaming: watchlist list", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var games []string
|
||||||
|
for rows.Next() {
|
||||||
|
var g string
|
||||||
|
if err := rows.Scan(&g); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
games = append(games, g)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(games) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Your release watchlist is empty. Use !releasewatch add <game> to add games.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Your Release Watchlist:\n")
|
||||||
|
for _, g := range games {
|
||||||
|
sb.WriteString(fmt.Sprintf(" - %s\n", g))
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
574
internal/plugin/holidays.go
Normal file
574
internal/plugin/holidays.go
Normal file
@@ -0,0 +1,574 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Holiday represents a single holiday entry.
|
||||||
|
type Holiday struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Country string `json:"country"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// holidaysDayData holds all holidays for a given date.
|
||||||
|
type holidaysDayData struct {
|
||||||
|
Holidays []Holiday `json:"holidays"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// calendarificResponse is the Calendarific API response structure.
|
||||||
|
type calendarificResponse struct {
|
||||||
|
Response struct {
|
||||||
|
Holidays []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
Country struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"country"`
|
||||||
|
Type []string `json:"type"`
|
||||||
|
Date struct {
|
||||||
|
ISO string `json:"iso"`
|
||||||
|
} `json:"date"`
|
||||||
|
} `json:"holidays"`
|
||||||
|
} `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// hebcalResponse is the HebCal API response structure.
|
||||||
|
type hebcalResponse struct {
|
||||||
|
Items []struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Date string `json:"date"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Memo string `json:"memo"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// aladhanResponse is the Aladhan API response for Hijri date conversion.
|
||||||
|
type aladhanResponse struct {
|
||||||
|
Data struct {
|
||||||
|
Hijri struct {
|
||||||
|
Date string `json:"date"`
|
||||||
|
Month struct {
|
||||||
|
En string `json:"en"`
|
||||||
|
} `json:"month"`
|
||||||
|
Year string `json:"year"`
|
||||||
|
Day string `json:"day"`
|
||||||
|
} `json:"hijri"`
|
||||||
|
} `json:"data"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// HolidaysPlugin provides multi-calendar holiday announcements.
|
||||||
|
type HolidaysPlugin struct {
|
||||||
|
Base
|
||||||
|
calendarificKey string
|
||||||
|
countries []string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHolidaysPlugin creates a new HolidaysPlugin.
|
||||||
|
func NewHolidaysPlugin(client *mautrix.Client) *HolidaysPlugin {
|
||||||
|
countries := []string{
|
||||||
|
"US", "GB", "CA", "AU", "PT", "IN", "JP",
|
||||||
|
"DE", "FR", "BR", "MX", "IT", "ES", "KR",
|
||||||
|
"NL", "SE", "NO", "IE", "NZ", "ZA", "PH",
|
||||||
|
}
|
||||||
|
if env := os.Getenv("HOLIDAY_COUNTRIES"); env != "" {
|
||||||
|
countries = nil
|
||||||
|
for _, c := range strings.Split(env, ",") {
|
||||||
|
c = strings.TrimSpace(strings.ToUpper(c))
|
||||||
|
if c != "" {
|
||||||
|
countries = append(countries, c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &HolidaysPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
calendarificKey: os.Getenv("CALENDARIFIC_API_KEY"),
|
||||||
|
countries: countries,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) Name() string { return "holidays" }
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "holidays", Description: "Show today's holidays (or week/month)", Usage: "!holidays [week|month]", Category: "Holidays"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "holidays") {
|
||||||
|
return p.handleHolidays(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefetch fetches today's holidays from all sources and stores them.
|
||||||
|
func (p *HolidaysPlugin) Prefetch() error {
|
||||||
|
today := time.Now().UTC()
|
||||||
|
dateStr := today.Format("2006-01-02")
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
var exists int
|
||||||
|
err := d.QueryRow(`SELECT 1 FROM holidays_log WHERE date = ?`, dateStr).Scan(&exists)
|
||||||
|
if err == nil {
|
||||||
|
slog.Info("holidays: already fetched for today", "date", dateStr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var allHolidays []Holiday
|
||||||
|
|
||||||
|
// Fetch from Calendarific for multiple countries
|
||||||
|
if p.calendarificKey != "" {
|
||||||
|
for _, country := range p.countries {
|
||||||
|
holidays, err := p.fetchCalendarific(today, country)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("holidays: calendarific fetch failed", "country", country, "err", err)
|
||||||
|
} else {
|
||||||
|
allHolidays = append(allHolidays, holidays...)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from HebCal
|
||||||
|
holidays, err := p.fetchHebCal(today)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("holidays: hebcal fetch failed", "err", err)
|
||||||
|
} else {
|
||||||
|
allHolidays = append(allHolidays, holidays...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch Islamic date info from Aladhan
|
||||||
|
islamicInfo, err := p.fetchAladhan(today)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("holidays: aladhan fetch failed", "err", err)
|
||||||
|
} else if islamicInfo != "" {
|
||||||
|
allHolidays = append(allHolidays, Holiday{
|
||||||
|
Name: "Islamic Date",
|
||||||
|
Description: islamicInfo,
|
||||||
|
Country: "International",
|
||||||
|
Type: "islamic-calendar",
|
||||||
|
Date: dateStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deduplicate by holiday name (case-insensitive)
|
||||||
|
allHolidays = dedupeHolidays(allHolidays)
|
||||||
|
|
||||||
|
data := holidaysDayData{Holidays: allHolidays}
|
||||||
|
jsonData, err := json.Marshal(data)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("holidays: marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO holidays_log (date, data, posted) VALUES (?, ?, 0)
|
||||||
|
ON CONFLICT(date) DO UPDATE SET data = ?`,
|
||||||
|
dateStr, string(jsonData), string(jsonData),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("holidays: store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("holidays: prefetched", "date", dateStr, "count", len(allHolidays))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostHolidays posts today's holidays to the given room.
|
||||||
|
func (p *HolidaysPlugin) PostHolidays(roomID id.RoomID) error {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
var dataStr string
|
||||||
|
var posted int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT data, posted FROM holidays_log WHERE date = ?`, today,
|
||||||
|
).Scan(&dataStr, &posted)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
slog.Warn("holidays: no entry for today, attempting prefetch", "date", today)
|
||||||
|
if err := p.Prefetch(); err != nil {
|
||||||
|
return fmt.Errorf("holidays: prefetch failed: %w", err)
|
||||||
|
}
|
||||||
|
err = d.QueryRow(
|
||||||
|
`SELECT data, posted FROM holidays_log WHERE date = ?`, today,
|
||||||
|
).Scan(&dataStr, &posted)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("holidays: still no entry after prefetch: %w", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("holidays: query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if posted == 1 {
|
||||||
|
slog.Info("holidays: already posted today", "date", today)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var data holidaysDayData
|
||||||
|
if err := json.Unmarshal([]byte(dataStr), &data); err != nil {
|
||||||
|
return fmt.Errorf("holidays: unmarshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data.Holidays) == 0 {
|
||||||
|
slog.Info("holidays: no holidays to post today")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := p.formatHolidays(today, data.Holidays)
|
||||||
|
if err := p.SendMessage(roomID, msg); err != nil {
|
||||||
|
return fmt.Errorf("holidays: send: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(`UPDATE holidays_log SET posted = 1 WHERE date = ?`, today)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("holidays: mark posted", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) handleHolidays(ctx MessageContext) error {
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "holidays"))
|
||||||
|
|
||||||
|
switch strings.ToLower(args) {
|
||||||
|
case "week":
|
||||||
|
return p.handleHolidaysRange(ctx, 7)
|
||||||
|
case "month":
|
||||||
|
return p.handleHolidaysRange(ctx, 30)
|
||||||
|
default:
|
||||||
|
return p.handleHolidaysToday(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) handleHolidaysToday(ctx MessageContext) error {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
var dataStr string
|
||||||
|
err := d.QueryRow(`SELECT data FROM holidays_log WHERE date = ?`, today).Scan(&dataStr)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Prefetch on demand
|
||||||
|
if pfErr := p.Prefetch(); pfErr != nil {
|
||||||
|
slog.Error("holidays: on-demand prefetch failed", "err", pfErr)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch holiday data. Try again later.")
|
||||||
|
}
|
||||||
|
err = d.QueryRow(`SELECT data FROM holidays_log WHERE date = ?`, today).Scan(&dataStr)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("holidays: query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch holiday data.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var data holidaysDayData
|
||||||
|
if err := json.Unmarshal([]byte(dataStr), &data); err != nil {
|
||||||
|
slog.Error("holidays: unmarshal", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse holiday data.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(data.Holidays) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No holidays today.")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := p.formatHolidays(today, data.Holidays)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) handleHolidaysRange(ctx MessageContext, days int) error {
|
||||||
|
d := db.Get()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
label := "This Week"
|
||||||
|
if days == 30 {
|
||||||
|
label = "This Month"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Holidays — %s\n", label))
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for i := 0; i < days; i++ {
|
||||||
|
date := now.AddDate(0, 0, i)
|
||||||
|
dateStr := date.Format("2006-01-02")
|
||||||
|
|
||||||
|
var dataStr string
|
||||||
|
err := d.QueryRow(`SELECT data FROM holidays_log WHERE date = ?`, dateStr).Scan(&dataStr)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var data holidaysDayData
|
||||||
|
if json.Unmarshal([]byte(dataStr), &data) != nil || len(data.Holidays) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
found = true
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s (%s):\n", dateStr, date.Format("Monday")))
|
||||||
|
for _, h := range p.selectFeatured(data.Holidays) {
|
||||||
|
sb.WriteString(fmt.Sprintf(" - %s", h.Name))
|
||||||
|
if h.Country != "" && h.Country != "International" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" [%s]", h.Country))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No holiday data available for that range.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) formatHolidays(date string, holidays []Holiday) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
t, _ := time.Parse("2006-01-02", date)
|
||||||
|
sb.WriteString(fmt.Sprintf("Today's Holidays — %s\n", t.Format("Monday, January 2, 2006")))
|
||||||
|
|
||||||
|
featured := p.selectFeatured(holidays)
|
||||||
|
for _, h := range featured {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n- %s", h.Name))
|
||||||
|
if h.Country != "" && h.Country != "International" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" [%s]", h.Country))
|
||||||
|
}
|
||||||
|
if h.Description != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n %s", h.Description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := len(holidays) - len(featured)
|
||||||
|
if remaining > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n\n...and %d more. Use !holidays for the full list.", remaining))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectFeatured picks the most interesting holidays to highlight (up to 8).
|
||||||
|
func (p *HolidaysPlugin) selectFeatured(holidays []Holiday) []Holiday {
|
||||||
|
if len(holidays) <= 8 {
|
||||||
|
return holidays
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prioritize national holidays and religious observances
|
||||||
|
priorityTypes := map[string]bool{
|
||||||
|
"national": true, "public": true, "religious": true,
|
||||||
|
"observance": true, "jewish": true, "islamic-calendar": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var featured, rest []Holiday
|
||||||
|
for _, h := range holidays {
|
||||||
|
if priorityTypes[strings.ToLower(h.Type)] {
|
||||||
|
featured = append(featured, h)
|
||||||
|
} else {
|
||||||
|
rest = append(rest, h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fill remaining slots
|
||||||
|
for _, h := range rest {
|
||||||
|
if len(featured) >= 8 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
featured = append(featured, h)
|
||||||
|
}
|
||||||
|
|
||||||
|
return featured
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeHolidayName strips common suffixes like "Day", "Eve" for dedup matching.
|
||||||
|
func normalizeHolidayName(name string) string {
|
||||||
|
n := strings.ToLower(strings.TrimSpace(name))
|
||||||
|
n = strings.TrimSuffix(n, " day")
|
||||||
|
n = strings.TrimSuffix(n, "'s")
|
||||||
|
n = strings.TrimSuffix(n, "s")
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// dedupeHolidays removes duplicate holidays by name or description.
|
||||||
|
func dedupeHolidays(holidays []Holiday) []Holiday {
|
||||||
|
seenName := make(map[string]int) // normalized name -> index in result
|
||||||
|
seenDesc := make(map[string]int) // lowercase description -> index in result
|
||||||
|
|
||||||
|
var result []Holiday
|
||||||
|
for _, h := range holidays {
|
||||||
|
nameLower := normalizeHolidayName(h.Name)
|
||||||
|
descLower := strings.ToLower(h.Description)
|
||||||
|
|
||||||
|
// Dedupe by name
|
||||||
|
if idx, ok := seenName[nameLower]; ok {
|
||||||
|
result[idx].Country = "International"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dedupe by description (catches "Eight Hours Day" == "Labour Day" etc.)
|
||||||
|
if descLower != "" {
|
||||||
|
if idx, ok := seenDesc[descLower]; ok {
|
||||||
|
result[idx].Country = "International"
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
seenName[nameLower] = len(result)
|
||||||
|
if descLower != "" {
|
||||||
|
seenDesc[descLower] = len(result)
|
||||||
|
}
|
||||||
|
result = append(result, h)
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) fetchCalendarific(date time.Time, country string) ([]Holiday, error) {
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"https://calendarific.com/api/v2/holidays?api_key=%s&country=%s&year=%d&month=%d&day=%d",
|
||||||
|
p.calendarificKey, country, date.Year(), int(date.Month()), date.Day(),
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("calendarific returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var calResp calendarificResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&calResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Types to exclude — too regional to be interesting
|
||||||
|
skipTypes := map[string]bool{
|
||||||
|
"Local holiday": true,
|
||||||
|
"Common local holiday": true,
|
||||||
|
"Local observance": true,
|
||||||
|
"Clock change/Daylight Saving Time": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var holidays []Holiday
|
||||||
|
for _, h := range calResp.Response.Holidays {
|
||||||
|
hType := "other"
|
||||||
|
if len(h.Type) > 0 {
|
||||||
|
hType = h.Type[0]
|
||||||
|
}
|
||||||
|
if skipTypes[hType] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
holidays = append(holidays, Holiday{
|
||||||
|
Name: h.Name,
|
||||||
|
Description: h.Description,
|
||||||
|
Country: h.Country.Name,
|
||||||
|
Type: hType,
|
||||||
|
Date: date.Format("2006-01-02"),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return holidays, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) fetchHebCal(date time.Time) ([]Holiday, error) {
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"https://www.hebcal.com/hebcal?v=1&cfg=json&year=%d&month=%d",
|
||||||
|
date.Year(), int(date.Month()),
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("hebcal returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var hebResp hebcalResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&hebResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dateStr := date.Format("2006-01-02")
|
||||||
|
var holidays []Holiday
|
||||||
|
for _, item := range hebResp.Items {
|
||||||
|
// HebCal dates are in YYYY-MM-DD format; match today
|
||||||
|
if len(item.Date) >= 10 && item.Date[:10] == dateStr {
|
||||||
|
holidays = append(holidays, Holiday{
|
||||||
|
Name: item.Title,
|
||||||
|
Description: item.Memo,
|
||||||
|
Country: "International",
|
||||||
|
Type: "jewish",
|
||||||
|
Date: dateStr,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return holidays, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HolidaysPlugin) fetchAladhan(date time.Time) (string, error) {
|
||||||
|
url := fmt.Sprintf(
|
||||||
|
"https://api.aladhan.com/v1/gToH/%02d-%02d-%d",
|
||||||
|
date.Day(), int(date.Month()), date.Year(),
|
||||||
|
)
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", fmt.Errorf("aladhan returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var aResp aladhanResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&aResp); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
hijri := aResp.Data.Hijri
|
||||||
|
if hijri.Day != "" && hijri.Month.En != "" && hijri.Year != "" {
|
||||||
|
return fmt.Sprintf("%s %s, %s AH", hijri.Day, hijri.Month.En, hijri.Year), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
217
internal/plugin/howami.go
Normal file
217
internal/plugin/howami.go
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HowAmIPlugin generates LLM-powered "roast" profiles of users.
|
||||||
|
type HowAmIPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHowAmIPlugin creates a new howami plugin.
|
||||||
|
func NewHowAmIPlugin(client *mautrix.Client) *HowAmIPlugin {
|
||||||
|
return &HowAmIPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HowAmIPlugin) Name() string { return "howami" }
|
||||||
|
|
||||||
|
func (p *HowAmIPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "howami", Description: "Get an LLM-generated roast profile", Usage: "!howami [@user]", Category: "LLM & Sentiment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HowAmIPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *HowAmIPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *HowAmIPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.IsCommand(ctx.Body, "howami") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ollamaHost := os.Getenv("OLLAMA_HOST")
|
||||||
|
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
||||||
|
if ollamaHost == "" || ollamaModel == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||||
|
}
|
||||||
|
|
||||||
|
target := ctx.Sender
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "howami"))
|
||||||
|
if args != "" {
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
if cleaned != "" {
|
||||||
|
target = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.SendReply(ctx.RoomID, ctx.EventID, "Gathering your profile data..."); err != nil {
|
||||||
|
slog.Error("howami: send thinking", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
profile := p.gatherProfile(target)
|
||||||
|
|
||||||
|
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||||
|
if botName == "" {
|
||||||
|
botName = "GogoBee"
|
||||||
|
}
|
||||||
|
prompt := fmt.Sprintf(
|
||||||
|
`You are a witty, playful community bot called %s. Based on the following user profile data, write a fun, lighthearted "roast" of this user in 3-5 sentences. Be creative, funny, and reference specific stats. Keep it friendly — no truly mean insults.
|
||||||
|
|
||||||
|
User: %s
|
||||||
|
|
||||||
|
Profile data:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Write the roast now. Do not include any preamble or explanation, just the roast text.`,
|
||||||
|
botName, string(target), profile,
|
||||||
|
)
|
||||||
|
|
||||||
|
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("howami: ollama call", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate profile. LLM might be offline.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *HowAmIPlugin) gatherProfile(userID id.UserID) string {
|
||||||
|
d := db.Get()
|
||||||
|
uid := string(userID)
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// XP and level
|
||||||
|
var xp, level int
|
||||||
|
if err := d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, uid).Scan(&xp, &level); err == nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("XP: %d, Level: %d\n", xp, level))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Message stats
|
||||||
|
var totalMsgs, totalWords, totalEmojis, totalQuestions, totalLinks int
|
||||||
|
if err := d.QueryRow(
|
||||||
|
`SELECT total_messages, total_words, total_emojis, total_questions, total_links
|
||||||
|
FROM user_stats WHERE user_id = ?`, uid,
|
||||||
|
).Scan(&totalMsgs, &totalWords, &totalEmojis, &totalQuestions, &totalLinks); err == nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("Messages: %d, Words: %d, Emojis: %d, Questions: %d, Links: %d\n",
|
||||||
|
totalMsgs, totalWords, totalEmojis, totalQuestions, totalLinks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Achievements
|
||||||
|
var achievementCount int
|
||||||
|
_ = d.QueryRow(`SELECT COUNT(*) FROM achievements WHERE user_id = ?`, uid).Scan(&achievementCount)
|
||||||
|
sb.WriteString(fmt.Sprintf("Achievements unlocked: %d\n", achievementCount))
|
||||||
|
|
||||||
|
// Sentiment stats
|
||||||
|
var positive, negative, neutral int
|
||||||
|
if err := d.QueryRow(
|
||||||
|
`SELECT positive, negative, neutral FROM sentiment_stats WHERE user_id = ?`, uid,
|
||||||
|
).Scan(&positive, &negative, &neutral); err == nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("Sentiment: %d positive, %d negative, %d neutral\n", positive, negative, neutral))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Profanity count
|
||||||
|
var profanityCount int
|
||||||
|
if err := d.QueryRow(`SELECT count FROM potty_mouth WHERE user_id = ?`, uid).Scan(&profanityCount); err == nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("Profanity count: %d\n", profanityCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insult stats
|
||||||
|
var timesInsulted, timesInsulting int
|
||||||
|
if err := d.QueryRow(
|
||||||
|
`SELECT times_insulted, times_insulting FROM insult_log WHERE user_id = ?`, uid,
|
||||||
|
).Scan(×Insulted, ×Insulting); err == nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("Times insulted: %d, Times insulting: %d\n", timesInsulted, timesInsulting))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trivia scores
|
||||||
|
var correct, wrong, totalScore int
|
||||||
|
var fastestMs sql.NullInt64
|
||||||
|
if err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(correct),0), COALESCE(SUM(wrong),0), COALESCE(SUM(total_score),0), MIN(fastest_ms)
|
||||||
|
FROM trivia_scores WHERE user_id = ?`, uid,
|
||||||
|
).Scan(&correct, &wrong, &totalScore, &fastestMs); err == nil {
|
||||||
|
sb.WriteString(fmt.Sprintf("Trivia: %d correct, %d wrong, score %d", correct, wrong, totalScore))
|
||||||
|
if fastestMs.Valid && fastestMs.Int64 > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf(", fastest: %dms", fastestMs.Int64))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOTD usage
|
||||||
|
var wotdTotal int
|
||||||
|
_ = d.QueryRow(`SELECT COALESCE(SUM(count),0) FROM wotd_usage WHERE user_id = ?`, uid).Scan(&wotdTotal)
|
||||||
|
sb.WriteString(fmt.Sprintf("Word of the Day uses: %d\n", wotdTotal))
|
||||||
|
|
||||||
|
// Reputation
|
||||||
|
var repXP int
|
||||||
|
_ = d.QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(amount),0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`, uid,
|
||||||
|
).Scan(&repXP)
|
||||||
|
sb.WriteString(fmt.Sprintf("Reputation XP: %d (rep points: %d)\n", repXP, repXP/5))
|
||||||
|
|
||||||
|
// Streaks
|
||||||
|
var activeDays int
|
||||||
|
_ = d.QueryRow(`SELECT COUNT(*) FROM daily_activity WHERE user_id = ?`, uid).Scan(&activeDays)
|
||||||
|
sb.WriteString(fmt.Sprintf("Active days: %d\n", activeDays))
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// callOllama sends a prompt to the Ollama generate endpoint and returns the response.
|
||||||
|
func callOllama(host, model, prompt string) (string, error) {
|
||||||
|
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": false,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("marshal payload: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 120 * time.Second}
|
||||||
|
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("ollama request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return "", fmt.Errorf("ollama HTTP %d: %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Response string `json:"response"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return "", fmt.Errorf("parse response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(result.Response), nil
|
||||||
|
}
|
||||||
678
internal/plugin/llm_passive.go
Normal file
678
internal/plugin/llm_passive.go
Normal file
@@ -0,0 +1,678 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// profanityKeywords is a basic list used for pre-filtering.
|
||||||
|
var profanityKeywords = []string{
|
||||||
|
"fuck", "shit", "damn", "hell", "ass", "bitch", "crap", "dick",
|
||||||
|
"bastard", "piss", "cock", "cunt", "douche", "dumbass", "idiot",
|
||||||
|
"moron", "stupid", "stfu", "wtf", "lmao", "lmfao",
|
||||||
|
}
|
||||||
|
|
||||||
|
var thanksPatternRe = regexp.MustCompile(`(?i)\b(thanks|thank\s+you|thankyou|thx|ty|tysm|tyvm|appreciate)\b`)
|
||||||
|
var mentionRe = regexp.MustCompile(`@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+`)
|
||||||
|
|
||||||
|
// classificationResult holds the parsed LLM JSON response.
|
||||||
|
type classificationResult struct {
|
||||||
|
Sentiment string `json:"sentiment"`
|
||||||
|
SentimentScore float64 `json:"sentiment_score"`
|
||||||
|
Topics []string `json:"topics"`
|
||||||
|
Profanity bool `json:"profanity"`
|
||||||
|
ProfanitySeverity int `json:"profanity_severity"`
|
||||||
|
InsultTarget string `json:"insult_target"`
|
||||||
|
WOTDUsed bool `json:"wotd_used"`
|
||||||
|
GratitudeTarget string `json:"gratitude_target"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// queueItem holds a message pending classification.
|
||||||
|
type queueItem struct {
|
||||||
|
UserID id.UserID
|
||||||
|
RoomID id.RoomID
|
||||||
|
EventID id.EventID
|
||||||
|
Body string
|
||||||
|
}
|
||||||
|
|
||||||
|
// LLMPassivePlugin classifies messages using Ollama and reacts accordingly.
|
||||||
|
type LLMPassivePlugin struct {
|
||||||
|
Base
|
||||||
|
xp *XPPlugin
|
||||||
|
ollamaHost string
|
||||||
|
ollamaModel string
|
||||||
|
sampleRate float64
|
||||||
|
enabled bool
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
queue []queueItem
|
||||||
|
backoff time.Duration
|
||||||
|
|
||||||
|
httpClient *http.Client
|
||||||
|
stopCh chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLLMPassivePlugin creates a new LLM passive classification plugin.
|
||||||
|
func NewLLMPassivePlugin(client *mautrix.Client, xp *XPPlugin) *LLMPassivePlugin {
|
||||||
|
host := os.Getenv("OLLAMA_HOST")
|
||||||
|
model := os.Getenv("OLLAMA_MODEL")
|
||||||
|
enabled := host != "" && model != ""
|
||||||
|
|
||||||
|
sampleRate := 0.15
|
||||||
|
if v := os.Getenv("LLM_SAMPLE_RATE"); v != "" {
|
||||||
|
if parsed, err := strconv.ParseFloat(v, 64); err == nil && parsed >= 0 && parsed <= 1 {
|
||||||
|
sampleRate = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
p := &LLMPassivePlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
xp: xp,
|
||||||
|
ollamaHost: host,
|
||||||
|
ollamaModel: model,
|
||||||
|
sampleRate: sampleRate,
|
||||||
|
enabled: enabled,
|
||||||
|
backoff: 5 * time.Second,
|
||||||
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
||||||
|
stopCh: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) Name() string { return "llm_passive" }
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "potty", Description: "Show profanity count for a user", Usage: "!potty [@user]", Category: "LLM & Sentiment"},
|
||||||
|
{Name: "pottyboard", Description: "Top 10 most profane users", Usage: "!pottyboard", Category: "LLM & Sentiment"},
|
||||||
|
{Name: "insults", Description: "Show insult stats for a user", Usage: "!insults [@user]", Category: "LLM & Sentiment"},
|
||||||
|
{Name: "insultboard", Description: "Top 10 most insulted users", Usage: "!insultboard", Category: "LLM & Sentiment"},
|
||||||
|
{Name: "sentiment", Description: "Show sentiment stats for a user", Usage: "!sentiment [@user]", Category: "LLM & Sentiment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) Init() error {
|
||||||
|
if p.enabled {
|
||||||
|
slog.Info("llm_passive: enabled", "host", p.ollamaHost, "model", p.ollamaModel, "sample_rate", p.sampleRate)
|
||||||
|
go p.processQueue()
|
||||||
|
} else {
|
||||||
|
slog.Warn("llm_passive: disabled (OLLAMA_HOST or OLLAMA_MODEL not set)",
|
||||||
|
"host", p.ollamaHost, "model", p.ollamaModel)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
// Handle commands
|
||||||
|
if p.IsCommand(ctx.Body, "potty") {
|
||||||
|
return p.handlePotty(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "pottyboard") {
|
||||||
|
return p.handlePottyboard(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "insults") {
|
||||||
|
return p.handleInsults(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "insultboard") {
|
||||||
|
return p.handleInsultboard(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "sentiment") {
|
||||||
|
return p.handleSentiment(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip command messages
|
||||||
|
if ctx.IsCommand {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pre-filter: only classify messages that match certain criteria
|
||||||
|
if !p.shouldClassify(ctx.Body) {
|
||||||
|
slog.Debug("llm_passive: message did not pass pre-filter", "body_len", len(ctx.Body))
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue for async classification
|
||||||
|
slog.Debug("llm_passive: enqueuing message for classification", "user", ctx.Sender, "body_len", len(ctx.Body))
|
||||||
|
p.mu.Lock()
|
||||||
|
p.queue = append(p.queue, queueItem{
|
||||||
|
UserID: ctx.Sender,
|
||||||
|
RoomID: ctx.RoomID,
|
||||||
|
EventID: ctx.EventID,
|
||||||
|
Body: ctx.Body,
|
||||||
|
})
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// shouldClassify applies pre-filtering heuristics.
|
||||||
|
func (p *LLMPassivePlugin) shouldClassify(body string) bool {
|
||||||
|
// Skip single-character messages (trivia answers, etc.)
|
||||||
|
if len(strings.TrimSpace(body)) <= 1 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
lower := strings.ToLower(body)
|
||||||
|
|
||||||
|
// Check profanity keywords
|
||||||
|
for _, kw := range profanityKeywords {
|
||||||
|
if strings.Contains(lower, kw) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check mentions
|
||||||
|
if mentionRe.MatchString(body) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check WOTD pattern (simple heuristic)
|
||||||
|
if strings.Contains(lower, "wotd") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check non-ASCII
|
||||||
|
for _, r := range body {
|
||||||
|
if r > unicode.MaxASCII {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check thanks patterns
|
||||||
|
if thanksPatternRe.MatchString(body) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Random sample of remaining messages (controlled by LLM_SAMPLE_RATE)
|
||||||
|
if p.sampleRate >= 1.0 || rand.Float64() < p.sampleRate {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// processQueue processes the classification queue with backoff.
|
||||||
|
func (p *LLMPassivePlugin) processQueue() {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-p.stopCh:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if len(p.queue) == 0 {
|
||||||
|
p.mu.Unlock()
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
item := p.queue[0]
|
||||||
|
p.queue = p.queue[1:]
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
slog.Debug("llm_passive: processing queued item", "user", item.UserID, "body_preview", truncate(item.Body, 50))
|
||||||
|
err := p.classifyAndProcess(item)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("llm: classification failed", "err", err, "user", item.UserID)
|
||||||
|
// Backoff on error
|
||||||
|
p.mu.Lock()
|
||||||
|
p.backoff *= 2
|
||||||
|
if p.backoff > 5*time.Minute {
|
||||||
|
p.backoff = 5 * time.Minute
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
time.Sleep(p.backoff)
|
||||||
|
} else {
|
||||||
|
// Reset backoff on success
|
||||||
|
p.mu.Lock()
|
||||||
|
p.backoff = 5 * time.Second
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// classifyAndProcess sends a message to Ollama and processes the result.
|
||||||
|
func (p *LLMPassivePlugin) classifyAndProcess(item queueItem) error {
|
||||||
|
result, err := p.callOllama(item.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ollama call: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Store classification
|
||||||
|
topicsJSON, _ := json.Marshal(result.Topics)
|
||||||
|
profanityInt := 0
|
||||||
|
if result.Profanity {
|
||||||
|
profanityInt = 1
|
||||||
|
}
|
||||||
|
wotdInt := 0
|
||||||
|
if result.WOTDUsed {
|
||||||
|
wotdInt = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO llm_classifications (user_id, room_id, message_text, sentiment, sentiment_score, topics, profanity, profanity_severity, insult_target, wotd_used, gratitude_target)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
string(item.UserID), string(item.RoomID), item.Body,
|
||||||
|
result.Sentiment, result.SentimentScore, string(topicsJSON),
|
||||||
|
profanityInt, result.ProfanitySeverity, result.InsultTarget, wotdInt, result.GratitudeTarget,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("llm: store classification", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate sentiment stats
|
||||||
|
switch result.Sentiment {
|
||||||
|
case "positive":
|
||||||
|
_, _ = d.Exec(
|
||||||
|
`INSERT INTO sentiment_stats (user_id, positive, total_score) VALUES (?, 1, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET positive = positive + 1, total_score = total_score + ?`,
|
||||||
|
string(item.UserID), result.SentimentScore, result.SentimentScore,
|
||||||
|
)
|
||||||
|
case "negative":
|
||||||
|
_, _ = d.Exec(
|
||||||
|
`INSERT INTO sentiment_stats (user_id, negative, total_score) VALUES (?, 1, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET negative = negative + 1, total_score = total_score + ?`,
|
||||||
|
string(item.UserID), result.SentimentScore, result.SentimentScore,
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
_, _ = d.Exec(
|
||||||
|
`INSERT INTO sentiment_stats (user_id, neutral, total_score) VALUES (?, 1, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET neutral = neutral + 1, total_score = total_score + ?`,
|
||||||
|
string(item.UserID), result.SentimentScore, result.SentimentScore,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track profanity with severity
|
||||||
|
if result.Profanity {
|
||||||
|
severity := result.ProfanitySeverity
|
||||||
|
if severity < 1 {
|
||||||
|
severity = 1
|
||||||
|
}
|
||||||
|
if severity > 3 {
|
||||||
|
severity = 3
|
||||||
|
}
|
||||||
|
mildInc, modInc, scorchInc := 0, 0, 0
|
||||||
|
switch severity {
|
||||||
|
case 1:
|
||||||
|
mildInc = 1
|
||||||
|
case 2:
|
||||||
|
modInc = 1
|
||||||
|
case 3:
|
||||||
|
scorchInc = 1
|
||||||
|
}
|
||||||
|
_, _ = d.Exec(
|
||||||
|
`INSERT INTO potty_mouth (user_id, count, mild, moderate, scorching) VALUES (?, 1, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET count = count + 1, mild = mild + ?, moderate = moderate + ?, scorching = scorching + ?`,
|
||||||
|
string(item.UserID), mildInc, modInc, scorchInc, mildInc, modInc, scorchInc,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// React if someone insults the bot
|
||||||
|
if result.InsultTarget == string(p.Client.UserID) {
|
||||||
|
botReactions := []string{
|
||||||
|
"\U0001f595", // 🖕
|
||||||
|
"\U0001f52a", // 🔪
|
||||||
|
"\U0001f5e1", // 🗡️
|
||||||
|
"\U0001fa78", // 🩸
|
||||||
|
"\U0001f480", // 💀
|
||||||
|
}
|
||||||
|
emoji := botReactions[rand.Intn(len(botReactions))]
|
||||||
|
_ = p.SendReact(item.RoomID, item.EventID, emoji)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track insults
|
||||||
|
if result.InsultTarget != "" {
|
||||||
|
_, _ = d.Exec(
|
||||||
|
`INSERT INTO insult_log (user_id, times_insulting) VALUES (?, 1)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET times_insulting = times_insulting + 1`,
|
||||||
|
string(item.UserID),
|
||||||
|
)
|
||||||
|
_, _ = d.Exec(
|
||||||
|
`INSERT INTO insult_log (user_id, times_insulted) VALUES (?, 1)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET times_insulted = times_insulted + 1`,
|
||||||
|
result.InsultTarget,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// React with emojis based on classification
|
||||||
|
if result.Sentiment == "positive" && result.SentimentScore > 0.7 {
|
||||||
|
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f44d") // thumbsup
|
||||||
|
}
|
||||||
|
if result.Sentiment == "negative" && result.SentimentScore < -0.7 {
|
||||||
|
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f44e") // thumbsdown
|
||||||
|
}
|
||||||
|
if result.Profanity {
|
||||||
|
switch result.ProfanitySeverity {
|
||||||
|
case 3:
|
||||||
|
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f92c") // 🤬
|
||||||
|
case 2:
|
||||||
|
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f632") // 😲
|
||||||
|
default:
|
||||||
|
_ = p.SendReact(item.RoomID, item.EventID, "\U0001fae3") // 🫣
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if result.WOTDUsed {
|
||||||
|
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f4d6") // open book
|
||||||
|
}
|
||||||
|
if result.GratitudeTarget != "" {
|
||||||
|
_ = p.SendReact(item.RoomID, item.EventID, "\U0001f49c") // purple heart
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant XP for gratitude
|
||||||
|
if result.GratitudeTarget != "" && p.xp != nil {
|
||||||
|
targetID := id.UserID(result.GratitudeTarget)
|
||||||
|
if targetID != item.UserID {
|
||||||
|
p.xp.GrantXP(targetID, 5, "gratitude")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ollamaRequest is the request body for the Ollama API.
|
||||||
|
type ollamaRequest struct {
|
||||||
|
Model string `json:"model"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ollamaResponse is the response from the Ollama API.
|
||||||
|
type ollamaResponse struct {
|
||||||
|
Response string `json:"response"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// callOllama sends a classification prompt to Ollama and parses the JSON result.
|
||||||
|
func (p *LLMPassivePlugin) callOllama(messageText string) (*classificationResult, error) {
|
||||||
|
prompt := fmt.Sprintf(`Classify the following chat message. Respond ONLY with valid JSON (no markdown, no explanation).
|
||||||
|
|
||||||
|
JSON schema:
|
||||||
|
{
|
||||||
|
"sentiment": "positive" | "negative" | "neutral",
|
||||||
|
"sentiment_score": number between -1.0 and 1.0,
|
||||||
|
"topics": ["topic1", "topic2"],
|
||||||
|
"profanity": true | false,
|
||||||
|
"profanity_severity": 0 | 1 | 2 | 3 (0=none, 1=mild e.g. damn/hell/crap, 2=moderate e.g. shit/ass/bitch, 3=scorching e.g. fuck/cunt and slurs),
|
||||||
|
"insult_target": "" or "@user:server" if someone is being insulted,
|
||||||
|
"wotd_used": true | false (if the message uses an unusual/sophisticated word),
|
||||||
|
"gratitude_target": "" or "@user:server" if thanking someone
|
||||||
|
}
|
||||||
|
|
||||||
|
Message: %s`, messageText)
|
||||||
|
|
||||||
|
reqBody := ollamaRequest{
|
||||||
|
Model: p.ollamaModel,
|
||||||
|
Prompt: prompt,
|
||||||
|
Stream: false,
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := json.Marshal(reqBody)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
url := strings.TrimRight(p.ollamaHost, "/") + "/api/generate"
|
||||||
|
slog.Debug("llm_passive: calling ollama", "url", url, "model", p.ollamaModel)
|
||||||
|
resp, err := p.httpClient.Post(url, "application/json", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("ollama request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
return nil, fmt.Errorf("ollama status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var ollamaResp ollamaResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode ollama response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := parseClassification(ollamaResp.Response)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("parse classification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseClassification parses a JSON classification response with repair logic.
|
||||||
|
func parseClassification(raw string) (*classificationResult, error) {
|
||||||
|
cleaned := raw
|
||||||
|
|
||||||
|
// Remove markdown code fences
|
||||||
|
cleaned = strings.TrimSpace(cleaned)
|
||||||
|
if strings.HasPrefix(cleaned, "```json") {
|
||||||
|
cleaned = strings.TrimPrefix(cleaned, "```json")
|
||||||
|
} else if strings.HasPrefix(cleaned, "```") {
|
||||||
|
cleaned = strings.TrimPrefix(cleaned, "```")
|
||||||
|
}
|
||||||
|
if strings.HasSuffix(cleaned, "```") {
|
||||||
|
cleaned = strings.TrimSuffix(cleaned, "```")
|
||||||
|
}
|
||||||
|
cleaned = strings.TrimSpace(cleaned)
|
||||||
|
|
||||||
|
// Try to parse directly first
|
||||||
|
var result classificationResult
|
||||||
|
if err := json.Unmarshal([]byte(cleaned), &result); err == nil {
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Repair: replace single quotes with double quotes
|
||||||
|
repaired := strings.ReplaceAll(cleaned, "'", "\"")
|
||||||
|
|
||||||
|
// Repair: remove trailing commas before } or ]
|
||||||
|
trailingCommaRe := regexp.MustCompile(`,\s*([}\]])`)
|
||||||
|
repaired = trailingCommaRe.ReplaceAllString(repaired, "$1")
|
||||||
|
|
||||||
|
if err := json.Unmarshal([]byte(repaired), &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON parse failed after repair: %w (raw: %s)", err, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncate(s string, n int) string {
|
||||||
|
if len(s) <= n {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:n] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
// Command handlers
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) handlePotty(ctx MessageContext) error {
|
||||||
|
target := ctx.Sender
|
||||||
|
args := p.GetArgs(ctx.Body, "potty")
|
||||||
|
if args != "" {
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
if cleaned != "" {
|
||||||
|
target = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
var count, mild, moderate, scorching int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(count, 0), COALESCE(mild, 0), COALESCE(moderate, 0), COALESCE(scorching, 0)
|
||||||
|
FROM potty_mouth WHERE user_id = ?`,
|
||||||
|
string(target),
|
||||||
|
).Scan(&count, &mild, &moderate, &scorching)
|
||||||
|
if err != nil {
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Profanity for %s: %s total (🫣 mild: %s, 😲 moderate: %s, 🤬 scorching: %s)",
|
||||||
|
string(target), formatNumber(count), formatNumber(mild), formatNumber(moderate), formatNumber(scorching))
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) handlePottyboard(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT user_id, count FROM potty_mouth ORDER BY count DESC LIMIT 10`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("llm: pottyboard query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load potty board.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Potty Mouth Board — Top 10\n\n")
|
||||||
|
|
||||||
|
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
|
||||||
|
i := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var count int
|
||||||
|
if err := rows.Scan(&userID, &count); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := fmt.Sprintf("#%d", i+1)
|
||||||
|
if i < len(medals) {
|
||||||
|
prefix = medals[i]
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s — %s incidents\n", prefix, userID, formatNumber(count)))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No profanity data yet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) handleInsults(ctx MessageContext) error {
|
||||||
|
target := ctx.Sender
|
||||||
|
args := p.GetArgs(ctx.Body, "insults")
|
||||||
|
if args != "" {
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
if cleaned != "" {
|
||||||
|
target = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
var insulted, insulting int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(times_insulted, 0), COALESCE(times_insulting, 0) FROM insult_log WHERE user_id = ?`,
|
||||||
|
string(target),
|
||||||
|
).Scan(&insulted, &insulting)
|
||||||
|
if err != nil {
|
||||||
|
insulted = 0
|
||||||
|
insulting = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Insult stats for %s:\nTimes insulted: %s\nTimes insulting others: %s",
|
||||||
|
string(target), formatNumber(insulted), formatNumber(insulting))
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) handleInsultboard(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT user_id, times_insulted FROM insult_log ORDER BY times_insulted DESC LIMIT 10`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("llm: insultboard query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load insult board.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Most Insulted — Top 10\n\n")
|
||||||
|
|
||||||
|
medals := []string{"\U0001f947", "\U0001f948", "\U0001f949"}
|
||||||
|
i := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var count int
|
||||||
|
if err := rows.Scan(&userID, &count); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := fmt.Sprintf("#%d", i+1)
|
||||||
|
if i < len(medals) {
|
||||||
|
prefix = medals[i]
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s — %s times\n", prefix, userID, formatNumber(count)))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No insult data yet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LLMPassivePlugin) handleSentiment(ctx MessageContext) error {
|
||||||
|
target := ctx.Sender
|
||||||
|
args := p.GetArgs(ctx.Body, "sentiment")
|
||||||
|
if args != "" {
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
if cleaned != "" {
|
||||||
|
target = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
var positive, negative, neutral int
|
||||||
|
var totalScore float64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(positive, 0), COALESCE(negative, 0), COALESCE(neutral, 0), COALESCE(total_score, 0)
|
||||||
|
FROM sentiment_stats WHERE user_id = ?`,
|
||||||
|
string(target),
|
||||||
|
).Scan(&positive, &negative, &neutral, &totalScore)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No sentiment data for %s yet.", string(target)))
|
||||||
|
}
|
||||||
|
|
||||||
|
total := positive + negative + neutral
|
||||||
|
avgScore := 0.0
|
||||||
|
if total > 0 {
|
||||||
|
avgScore = totalScore / float64(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
mood := "neutral"
|
||||||
|
if avgScore > 0.3 {
|
||||||
|
mood = "mostly positive"
|
||||||
|
} else if avgScore > 0.1 {
|
||||||
|
mood = "leaning positive"
|
||||||
|
} else if avgScore < -0.3 {
|
||||||
|
mood = "mostly negative"
|
||||||
|
} else if avgScore < -0.1 {
|
||||||
|
mood = "leaning negative"
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Sentiment for %s:\n😊 Positive: %s 😐 Neutral: %s 😠 Negative: %s\nAverage mood: %.2f (%s)",
|
||||||
|
string(target), formatNumber(positive), formatNumber(neutral), formatNumber(negative), avgScore, mood)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
334
internal/plugin/lookup.go
Normal file
334
internal/plugin/lookup.go
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
var httpClient = &http.Client{Timeout: 15 * time.Second}
|
||||||
|
|
||||||
|
// LookupPlugin provides Wikipedia, dictionary, Urban Dictionary, and translation lookups.
|
||||||
|
type LookupPlugin struct {
|
||||||
|
Base
|
||||||
|
rateLimiter *RateLimitsPlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLookupPlugin creates a new lookup plugin.
|
||||||
|
func NewLookupPlugin(client *mautrix.Client, rateLimiter *RateLimitsPlugin) *LookupPlugin {
|
||||||
|
return &LookupPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
rateLimiter: rateLimiter,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LookupPlugin) Name() string { return "lookup" }
|
||||||
|
|
||||||
|
func (p *LookupPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "wiki", Description: "Look up a Wikipedia summary", Usage: "!wiki <topic>", Category: "Lookup & Reference"},
|
||||||
|
{Name: "define", Description: "Look up a word definition", Usage: "!define <word>", Category: "Lookup & Reference"},
|
||||||
|
{Name: "urban", Description: "Look up Urban Dictionary definition", Usage: "!urban <term>", Category: "Lookup & Reference"},
|
||||||
|
{Name: "translate", Description: "Translate text (pt/es/fr/de/ja/ko/zh/ar/ru/it)", Usage: "!translate [lang] <text>", Category: "Lookup & Reference"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LookupPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *LookupPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *LookupPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
switch {
|
||||||
|
case p.IsCommand(ctx.Body, "wiki"):
|
||||||
|
return p.handleWiki(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "define"):
|
||||||
|
return p.handleDefine(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "urban"):
|
||||||
|
return p.handleUrban(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "translate"):
|
||||||
|
return p.handleTranslate(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LookupPlugin) handleWiki(ctx MessageContext) error {
|
||||||
|
topic := strings.TrimSpace(p.GetArgs(ctx.Body, "wiki"))
|
||||||
|
if topic == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !wiki <topic>")
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := url.PathEscape(strings.ReplaceAll(topic, " ", "_"))
|
||||||
|
apiURL := fmt.Sprintf("https://en.wikipedia.org/api/rest_v1/page/summary/%s", encoded)
|
||||||
|
|
||||||
|
resp, err := httpClient.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("lookup: wiki request", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach Wikipedia.")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No Wikipedia article found for \"%s\".", topic))
|
||||||
|
}
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Wikipedia returned an error.")
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read Wikipedia response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Extract string `json:"extract"`
|
||||||
|
ContentURLs struct {
|
||||||
|
Desktop struct {
|
||||||
|
Page string `json:"page"`
|
||||||
|
} `json:"desktop"`
|
||||||
|
} `json:"content_urls"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse Wikipedia response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
extract := result.Extract
|
||||||
|
if len(extract) > 500 {
|
||||||
|
extract = extract[:500] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("%s\n\n%s\n\n%s", result.Title, extract, result.ContentURLs.Desktop.Page)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LookupPlugin) handleDefine(ctx MessageContext) error {
|
||||||
|
word := strings.TrimSpace(p.GetArgs(ctx.Body, "define"))
|
||||||
|
if word == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !define <word>")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://api.dictionaryapi.dev/api/v2/entries/en/%s", url.PathEscape(word))
|
||||||
|
|
||||||
|
resp, err := httpClient.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("lookup: define request", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach dictionary API.")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
|
||||||
|
}
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read dictionary response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries []struct {
|
||||||
|
Word string `json:"word"`
|
||||||
|
Meanings []struct {
|
||||||
|
PartOfSpeech string `json:"partOfSpeech"`
|
||||||
|
Definitions []struct {
|
||||||
|
Definition string `json:"definition"`
|
||||||
|
Example string `json:"example"`
|
||||||
|
} `json:"definitions"`
|
||||||
|
} `json:"meanings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &entries); err != nil || len(entries) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No definition found for \"%s\".", word))
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := entries[0]
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Definition of \"%s\":\n\n", entry.Word))
|
||||||
|
|
||||||
|
for i, meaning := range entry.Meanings {
|
||||||
|
if i >= 3 { // Limit to 3 meanings
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("(%s)\n", meaning.PartOfSpeech))
|
||||||
|
for j, def := range meaning.Definitions {
|
||||||
|
if j >= 2 { // Limit to 2 definitions per meaning
|
||||||
|
break
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" %d. %s\n", j+1, def.Definition))
|
||||||
|
if def.Example != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" Example: \"%s\"\n", def.Example))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LookupPlugin) handleUrban(ctx MessageContext) error {
|
||||||
|
term := strings.TrimSpace(p.GetArgs(ctx.Body, "urban"))
|
||||||
|
if term == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !urban <term>")
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
termLower := strings.ToLower(term)
|
||||||
|
|
||||||
|
// Check cache (24h)
|
||||||
|
var cachedData string
|
||||||
|
var cachedAt int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT data, cached_at FROM urban_cache WHERE term = ?`,
|
||||||
|
termLower,
|
||||||
|
).Scan(&cachedData, &cachedAt)
|
||||||
|
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
if err == nil && now-cachedAt < 86400 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, cachedData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from API
|
||||||
|
apiURL := fmt.Sprintf("https://api.urbandictionary.com/v0/define?term=%s", url.QueryEscape(term))
|
||||||
|
|
||||||
|
resp, err := httpClient.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("lookup: urban request", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach Urban Dictionary.")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read Urban Dictionary response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
List []struct {
|
||||||
|
Word string `json:"word"`
|
||||||
|
Definition string `json:"definition"`
|
||||||
|
Example string `json:"example"`
|
||||||
|
ThumbsUp int `json:"thumbs_up"`
|
||||||
|
ThumbsDown int `json:"thumbs_down"`
|
||||||
|
} `json:"list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil || len(result.List) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No Urban Dictionary definition found for \"%s\".", term))
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := result.List[0]
|
||||||
|
def := entry.Definition
|
||||||
|
// Remove bracket notation used by Urban Dictionary
|
||||||
|
def = strings.ReplaceAll(def, "[", "")
|
||||||
|
def = strings.ReplaceAll(def, "]", "")
|
||||||
|
if len(def) > 400 {
|
||||||
|
def = def[:400] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
example := entry.Example
|
||||||
|
example = strings.ReplaceAll(example, "[", "")
|
||||||
|
example = strings.ReplaceAll(example, "]", "")
|
||||||
|
if len(example) > 200 {
|
||||||
|
example = example[:200] + "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Urban Dictionary: %s\n\n", entry.Word))
|
||||||
|
sb.WriteString(fmt.Sprintf("%s\n", def))
|
||||||
|
if example != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\nExample: %s\n", example))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n+%d / -%d", entry.ThumbsUp, entry.ThumbsDown))
|
||||||
|
|
||||||
|
msg := sb.String()
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO urban_cache (term, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(term) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
termLower, msg, now, msg, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("lookup: urban cache", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
var supportedLangs = map[string]bool{
|
||||||
|
"pt": true, "es": true, "fr": true, "de": true, "ja": true,
|
||||||
|
"ko": true, "zh": true, "ar": true, "ru": true, "it": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *LookupPlugin) handleTranslate(ctx MessageContext) error {
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "translate"))
|
||||||
|
if args == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Usage: !translate [lang] <text>\nSupported: pt, es, fr, de, ja, ko, zh, ar, ru, it")
|
||||||
|
}
|
||||||
|
|
||||||
|
ltURL := os.Getenv("LIBRETRANSLATE_URL")
|
||||||
|
if ltURL == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Translation service is not configured.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limit: 10 translations per day
|
||||||
|
if p.rateLimiter != nil && !p.rateLimiter.CheckLimit(ctx.Sender, "translate", 10) {
|
||||||
|
remaining := p.rateLimiter.Remaining(ctx.Sender, "translate", 10)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Translation rate limit reached. %d remaining today.", remaining))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse lang code and text
|
||||||
|
parts := strings.SplitN(args, " ", 2)
|
||||||
|
targetLang := "es" // default
|
||||||
|
text := args
|
||||||
|
|
||||||
|
if len(parts) >= 2 && supportedLangs[strings.ToLower(parts[0])] {
|
||||||
|
targetLang = strings.ToLower(parts[0])
|
||||||
|
text = parts[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Call LibreTranslate
|
||||||
|
payload := fmt.Sprintf(`{"q":%q,"source":"auto","target":%q}`, text, targetLang)
|
||||||
|
req, err := http.NewRequest("POST", strings.TrimRight(ltURL, "/")+"/translate", strings.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to create translation request.")
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("lookup: translate request", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to reach translation service.")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read translation response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
TranslatedText string `json:"translatedText"`
|
||||||
|
Error string `json:"error"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to parse translation response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if result.Error != "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Translation error: %s", result.Error))
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Translation (-> %s):\n%s", targetLang, result.TranslatedText)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
198
internal/plugin/markov.go
Normal file
198
internal/plugin/markov.go
Normal file
@@ -0,0 +1,198 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MarkovPlugin collects messages and generates trigram-based text.
|
||||||
|
type MarkovPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMarkovPlugin creates a new Markov chain plugin.
|
||||||
|
func NewMarkovPlugin(client *mautrix.Client) *MarkovPlugin {
|
||||||
|
return &MarkovPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MarkovPlugin) Name() string { return "markov" }
|
||||||
|
|
||||||
|
func (p *MarkovPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "markov", Description: "Generate Markov chain text from a user's messages", Usage: "!markov [@user|me]", Category: "Fun & Games"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MarkovPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *MarkovPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *MarkovPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "markov") {
|
||||||
|
return p.handleMarkov(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive: collect non-command messages
|
||||||
|
if !ctx.IsCommand {
|
||||||
|
p.collectMessage(ctx.Sender, ctx.Body)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectMessage stores a message in the markov_corpus, capping at 10,000 per user.
|
||||||
|
func (p *MarkovPlugin) collectMessage(userID id.UserID, text string) {
|
||||||
|
// Skip very short messages
|
||||||
|
if len(strings.Fields(text)) < 3 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO markov_corpus (user_id, text) VALUES (?, ?)`,
|
||||||
|
string(userID), text,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("markov: insert message", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cap at 10,000 messages per user — delete oldest excess
|
||||||
|
_, err = d.Exec(
|
||||||
|
`DELETE FROM markov_corpus WHERE user_id = ? AND id NOT IN (
|
||||||
|
SELECT id FROM markov_corpus WHERE user_id = ? ORDER BY id DESC LIMIT 10000
|
||||||
|
)`,
|
||||||
|
string(userID), string(userID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("markov: prune corpus", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MarkovPlugin) handleMarkov(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
args := p.GetArgs(ctx.Body, "markov")
|
||||||
|
|
||||||
|
var targetUser id.UserID
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case args == "":
|
||||||
|
// No argument — pick a random user from the corpus
|
||||||
|
var randomUser string
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT user_id FROM markov_corpus ORDER BY RANDOM() LIMIT 1`,
|
||||||
|
).Scan(&randomUser)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No Markov data available yet.")
|
||||||
|
}
|
||||||
|
targetUser = id.UserID(randomUser)
|
||||||
|
case args == "me":
|
||||||
|
targetUser = ctx.Sender
|
||||||
|
default:
|
||||||
|
// Treat as user ID
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
targetUser = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch corpus for the user
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT text FROM markov_corpus WHERE user_id = ?`,
|
||||||
|
string(targetUser),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("markov: query corpus", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load Markov data.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var texts []string
|
||||||
|
for rows.Next() {
|
||||||
|
var t string
|
||||||
|
if err := rows.Scan(&t); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
texts = append(texts, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(texts) < 10 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Not enough data for %s (need at least 10 messages).", string(targetUser)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build trigram model and generate
|
||||||
|
result := generateMarkov(texts, 50)
|
||||||
|
if result == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to generate Markov text.")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("[%s]: %s", string(targetUser), result)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// trigram key
|
||||||
|
type trigramKey struct {
|
||||||
|
w1, w2 string
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateMarkov builds a trigram model from texts and generates output.
|
||||||
|
func generateMarkov(texts []string, maxWords int) string {
|
||||||
|
chain := make(map[trigramKey][]string)
|
||||||
|
var starters []trigramKey
|
||||||
|
|
||||||
|
for _, text := range texts {
|
||||||
|
words := strings.Fields(text)
|
||||||
|
if len(words) < 3 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
starters = append(starters, trigramKey{words[0], words[1]})
|
||||||
|
|
||||||
|
for i := 0; i < len(words)-2; i++ {
|
||||||
|
key := trigramKey{words[i], words[i+1]}
|
||||||
|
chain[key] = append(chain[key], words[i+2])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(starters) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick a random starter
|
||||||
|
start := starters[rand.Intn(len(starters))]
|
||||||
|
result := []string{start.w1, start.w2}
|
||||||
|
|
||||||
|
for len(result) < maxWords {
|
||||||
|
key := trigramKey{result[len(result)-2], result[len(result)-1]}
|
||||||
|
nextWords, ok := chain[key]
|
||||||
|
if !ok || len(nextWords) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
next := nextWords[rand.Intn(len(nextWords))]
|
||||||
|
result = append(result, next)
|
||||||
|
|
||||||
|
// Stop at sentence-ending punctuation sometimes
|
||||||
|
if len(result) > 8 && endsWithPunctuation(next) && rand.Float64() < 0.3 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(result, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
func endsWithPunctuation(s string) bool {
|
||||||
|
if s == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
last := s[len(s)-1]
|
||||||
|
return last == '.' || last == '!' || last == '?'
|
||||||
|
}
|
||||||
620
internal/plugin/movies.go
Normal file
620
internal/plugin/movies.go
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tmdbSearchResult represents a result from the TMDB search API.
|
||||||
|
type tmdbSearchResult struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Name string `json:"name"` // for TV shows
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
FirstAirDate string `json:"first_air_date"` // for TV shows
|
||||||
|
VoteAverage float64 `json:"vote_average"`
|
||||||
|
VoteCount int `json:"vote_count"`
|
||||||
|
MediaType string `json:"media_type"`
|
||||||
|
PosterPath string `json:"poster_path"`
|
||||||
|
GenreIDs []int `json:"genre_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tmdbSearchResponse is the TMDB search API response.
|
||||||
|
type tmdbSearchResponse struct {
|
||||||
|
Page int `json:"page"`
|
||||||
|
Results []tmdbSearchResult `json:"results"`
|
||||||
|
TotalResults int `json:"total_results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tmdbMovieDetail has additional movie details.
|
||||||
|
type tmdbMovieDetail struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
ReleaseDate string `json:"release_date"`
|
||||||
|
VoteAverage float64 `json:"vote_average"`
|
||||||
|
VoteCount int `json:"vote_count"`
|
||||||
|
Runtime int `json:"runtime"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Tagline string `json:"tagline"`
|
||||||
|
Budget int64 `json:"budget"`
|
||||||
|
Revenue int64 `json:"revenue"`
|
||||||
|
Genres []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"genres"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tmdbTVDetail has additional TV show details.
|
||||||
|
type tmdbTVDetail struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Overview string `json:"overview"`
|
||||||
|
FirstAirDate string `json:"first_air_date"`
|
||||||
|
VoteAverage float64 `json:"vote_average"`
|
||||||
|
VoteCount int `json:"vote_count"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Tagline string `json:"tagline"`
|
||||||
|
NumSeasons int `json:"number_of_seasons"`
|
||||||
|
NumEpisodes int `json:"number_of_episodes"`
|
||||||
|
Genres []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"genres"`
|
||||||
|
Networks []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"networks"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// tmdbUpcomingResponse is the TMDB upcoming movies response.
|
||||||
|
type tmdbUpcomingResponse struct {
|
||||||
|
Results []tmdbSearchResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MoviesPlugin provides movie and TV lookups via TMDB.
|
||||||
|
type MoviesPlugin struct {
|
||||||
|
Base
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMoviesPlugin creates a new MoviesPlugin.
|
||||||
|
func NewMoviesPlugin(client *mautrix.Client) *MoviesPlugin {
|
||||||
|
return &MoviesPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
apiKey: os.Getenv("TMDB_API_KEY"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) Name() string { return "movies" }
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "movie", Description: "Search for movie info", Usage: "!movie <title>", Category: "Entertainment"},
|
||||||
|
{Name: "tv", Description: "Search for TV show info", Usage: "!tv <title>", Category: "Entertainment"},
|
||||||
|
{Name: "movie watch", Description: "Add a movie to your watchlist", Usage: "!movie watch <title>", Category: "Entertainment"},
|
||||||
|
{Name: "movie watching", Description: "List your movie watchlist", Usage: "!movie watching", Category: "Entertainment"},
|
||||||
|
{Name: "movie unwatch", Description: "Remove from watchlist by TMDB ID", Usage: "!movie unwatch <id>", Category: "Entertainment"},
|
||||||
|
{Name: "upcoming movies", Description: "Show upcoming movies", Usage: "!upcoming movies", Category: "Entertainment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "movie") {
|
||||||
|
return p.handleMovie(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "tv") {
|
||||||
|
return p.handleTV(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "upcoming") {
|
||||||
|
args := p.GetArgs(ctx.Body, "upcoming")
|
||||||
|
if strings.ToLower(strings.TrimSpace(args)) == "movies" {
|
||||||
|
return p.handleUpcoming(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) handleMovie(ctx MessageContext) error {
|
||||||
|
args := p.GetArgs(ctx.Body, "movie")
|
||||||
|
if args == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Usage: !movie <title> | !movie watch|watching|unwatch <title|id>")
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.SplitN(args, " ", 2)
|
||||||
|
sub := strings.ToLower(parts[0])
|
||||||
|
|
||||||
|
switch sub {
|
||||||
|
case "watch":
|
||||||
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !movie watch <title>")
|
||||||
|
}
|
||||||
|
return p.handleWatchMovie(ctx, strings.TrimSpace(parts[1]))
|
||||||
|
case "watching":
|
||||||
|
return p.handleWatching(ctx)
|
||||||
|
case "unwatch":
|
||||||
|
if len(parts) < 2 || strings.TrimSpace(parts[1]) == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !movie unwatch <id>")
|
||||||
|
}
|
||||||
|
return p.handleUnwatch(ctx, strings.TrimSpace(parts[1]))
|
||||||
|
default:
|
||||||
|
return p.handleMovieSearch(ctx, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) handleMovieSearch(ctx MessageContext, query string) error {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := p.searchAndFetchMovie(query)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("movies: search failed", "query", query, "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for movie.")
|
||||||
|
}
|
||||||
|
if detail == nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No movies found for \"%s\".", query))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatMovie(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) searchAndFetchMovie(query string) (*tmdbMovieDetail, error) {
|
||||||
|
encoded := url.QueryEscape(query)
|
||||||
|
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/movie?api_key=%s&query=%s&page=1",
|
||||||
|
p.apiKey, encoded)
|
||||||
|
|
||||||
|
var searchResp tmdbSearchResponse
|
||||||
|
if err := p.tmdbGet(apiURL, &searchResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Results) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
movieID := searchResp.Results[0].ID
|
||||||
|
return p.fetchMovieDetail(movieID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) fetchMovieDetail(movieID int) (*tmdbMovieDetail, error) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Check cache (24h TTL)
|
||||||
|
var cached string
|
||||||
|
var cachedAt int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT data, cached_at FROM movie_cache WHERE tmdb_id = ?`, movieID,
|
||||||
|
).Scan(&cached, &cachedAt)
|
||||||
|
if err == nil && time.Now().Unix()-cachedAt < 24*3600 {
|
||||||
|
var detail tmdbMovieDetail
|
||||||
|
if json.Unmarshal([]byte(cached), &detail) == nil {
|
||||||
|
return &detail, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/%d?api_key=%s", movieID, p.apiKey)
|
||||||
|
var detail tmdbMovieDetail
|
||||||
|
if err := p.tmdbGet(apiURL, &detail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
data, _ := json.Marshal(detail)
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO movie_cache (tmdb_id, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(tmdb_id) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
movieID, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("movies: cache write", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) formatMovie(m *tmdbMovieDetail) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("%s", m.Title))
|
||||||
|
if m.ReleaseDate != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", m.ReleaseDate); err == nil {
|
||||||
|
sb.WriteString(fmt.Sprintf(" (%d)", t.Year()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
if m.Tagline != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\"%s\"\n", m.Tagline))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.VoteAverage > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Rating: %.1f/10 (%d votes)\n", m.VoteAverage, m.VoteCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Runtime > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Runtime: %dh %dm\n", m.Runtime/60, m.Runtime%60))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Status != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Status: %s\n", m.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.Genres) > 0 {
|
||||||
|
genres := make([]string, len(m.Genres))
|
||||||
|
for i, g := range m.Genres {
|
||||||
|
genres[i] = g.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Overview != "" {
|
||||||
|
overview := m.Overview
|
||||||
|
if len(overview) > 400 {
|
||||||
|
overview = overview[:400] + "..."
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s\n", overview))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("\nhttps://www.themoviedb.org/movie/%d", m.ID))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) handleTV(ctx MessageContext) error {
|
||||||
|
query := p.GetArgs(ctx.Body, "tv")
|
||||||
|
if query == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !tv <title>")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.apiKey == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "TV lookups are not configured (missing API key).")
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := url.QueryEscape(query)
|
||||||
|
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/tv?api_key=%s&query=%s&page=1",
|
||||||
|
p.apiKey, encoded)
|
||||||
|
|
||||||
|
var searchResp tmdbSearchResponse
|
||||||
|
if err := p.tmdbGet(apiURL, &searchResp); err != nil {
|
||||||
|
slog.Error("movies: tv search failed", "query", query, "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for TV show.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Results) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No TV shows found for \"%s\".", query))
|
||||||
|
}
|
||||||
|
|
||||||
|
tvID := searchResp.Results[0].ID
|
||||||
|
detail, err := p.fetchTVDetail(tvID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("movies: tv detail fetch failed", "id", tvID, "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch TV show details.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, p.formatTV(detail))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) fetchTVDetail(tvID int) (*tmdbTVDetail, error) {
|
||||||
|
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/tv/%d?api_key=%s", tvID, p.apiKey)
|
||||||
|
var detail tmdbTVDetail
|
||||||
|
if err := p.tmdbGet(apiURL, &detail); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &detail, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) formatTV(tv *tmdbTVDetail) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("%s", tv.Name))
|
||||||
|
if tv.FirstAirDate != "" {
|
||||||
|
if t, err := time.Parse("2006-01-02", tv.FirstAirDate); err == nil {
|
||||||
|
sb.WriteString(fmt.Sprintf(" (%d)", t.Year()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
|
||||||
|
if tv.Tagline != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\"%s\"\n", tv.Tagline))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tv.VoteAverage > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Rating: %.1f/10 (%d votes)\n", tv.VoteAverage, tv.VoteCount))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("Seasons: %d | Episodes: %d\n", tv.NumSeasons, tv.NumEpisodes))
|
||||||
|
|
||||||
|
if tv.Status != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Status: %s\n", tv.Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tv.Genres) > 0 {
|
||||||
|
genres := make([]string, len(tv.Genres))
|
||||||
|
for i, g := range tv.Genres {
|
||||||
|
genres[i] = g.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tv.Networks) > 0 {
|
||||||
|
networks := make([]string, len(tv.Networks))
|
||||||
|
for i, n := range tv.Networks {
|
||||||
|
networks[i] = n.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Networks: %s\n", strings.Join(networks, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if tv.Overview != "" {
|
||||||
|
overview := tv.Overview
|
||||||
|
if len(overview) > 400 {
|
||||||
|
overview = overview[:400] + "..."
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s\n", overview))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("\nhttps://www.themoviedb.org/tv/%d", tv.ID))
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) handleWatchMovie(ctx MessageContext, title string) error {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||||
|
}
|
||||||
|
|
||||||
|
encoded := url.QueryEscape(title)
|
||||||
|
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/search/movie?api_key=%s&query=%s&page=1",
|
||||||
|
p.apiKey, encoded)
|
||||||
|
|
||||||
|
var searchResp tmdbSearchResponse
|
||||||
|
if err := p.tmdbGet(apiURL, &searchResp); err != nil {
|
||||||
|
slog.Error("movies: watch search failed", "title", title, "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for movie.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(searchResp.Results) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No movies found for \"%s\".", title))
|
||||||
|
}
|
||||||
|
|
||||||
|
result := searchResp.Results[0]
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO movie_watchlist (user_id, tmdb_id, title, media_type, room_id) VALUES (?, ?, ?, 'movie', ?)
|
||||||
|
ON CONFLICT(user_id, tmdb_id) DO NOTHING`,
|
||||||
|
string(ctx.Sender), result.ID, result.Title, string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("movies: watchlist add", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Added \"%s\" (TMDB ID: %d) to your movie watchlist.", result.Title, result.ID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) handleWatching(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT tmdb_id, title, media_type FROM movie_watchlist WHERE user_id = ? ORDER BY title`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("movies: watching list", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Your Movie/TV Watchlist:\n\n")
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var tmdbID int
|
||||||
|
var title, mediaType string
|
||||||
|
if err := rows.Scan(&tmdbID, &title, &mediaType); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" [%d] %s (%s)\n", tmdbID, title, mediaType))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if count == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Your movie watchlist is empty. Use !movie watch <title> to add movies.")
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\nUse !movie unwatch <id> to remove an entry.")
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) handleUnwatch(ctx MessageContext, idStr string) error {
|
||||||
|
tmdbID, err := strconv.Atoi(idStr)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
"Please provide a valid TMDB ID number. Check !movie watching for your list.")
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
res, err := d.Exec(
|
||||||
|
`DELETE FROM movie_watchlist WHERE user_id = ? AND tmdb_id = ?`,
|
||||||
|
string(ctx.Sender), tmdbID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("movies: unwatch", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("TMDB ID %d is not in your watchlist.", tmdbID))
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed TMDB ID %d from your watchlist.", tmdbID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) handleUpcoming(ctx MessageContext) error {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Movie lookups are not configured (missing API key).")
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := fmt.Sprintf("https://api.themoviedb.org/3/movie/upcoming?api_key=%s®ion=US&page=1", p.apiKey)
|
||||||
|
|
||||||
|
var resp tmdbUpcomingResponse
|
||||||
|
if err := p.tmdbGet(apiURL, &resp); err != nil {
|
||||||
|
slog.Error("movies: upcoming fetch failed", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch upcoming movies.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(resp.Results) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No upcoming movies found.")
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 10
|
||||||
|
if len(resp.Results) < limit {
|
||||||
|
limit = len(resp.Results)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Upcoming Movies:\n\n")
|
||||||
|
|
||||||
|
for i := 0; i < limit; i++ {
|
||||||
|
r := resp.Results[i]
|
||||||
|
title := r.Title
|
||||||
|
if title == "" {
|
||||||
|
title = r.Name
|
||||||
|
}
|
||||||
|
dateStr := r.ReleaseDate
|
||||||
|
if dateStr == "" {
|
||||||
|
dateStr = "TBA"
|
||||||
|
}
|
||||||
|
ratingStr := ""
|
||||||
|
if r.VoteAverage > 0 {
|
||||||
|
ratingStr = fmt.Sprintf(" - %.1f/10", r.VoteAverage)
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s (%s)%s\n", i+1, title, dateStr, ratingStr))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *MoviesPlugin) tmdbGet(apiURL string, target interface{}) error {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("tmdb returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.NewDecoder(resp.Body).Decode(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostDailyReleases checks release dates against watchlist and DMs users about releases.
|
||||||
|
// Intended to be called by the scheduler daily.
|
||||||
|
func (p *MoviesPlugin) PostDailyReleases(roomID id.RoomID) {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
slog.Warn("movies: skipping daily releases, no API key configured")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
if db.JobCompleted("movie_releases", today) {
|
||||||
|
slog.Info("movies: already sent daily releases", "date", today)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
success := false
|
||||||
|
defer func() {
|
||||||
|
if success {
|
||||||
|
db.MarkJobCompleted("movie_releases", today)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Get all unique TMDB IDs from watchlists
|
||||||
|
rows, err := d.Query(`SELECT DISTINCT tmdb_id, title, media_type FROM movie_watchlist`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("movies: daily releases query", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
type watchedItem struct {
|
||||||
|
tmdbID int
|
||||||
|
title string
|
||||||
|
mediaType string
|
||||||
|
}
|
||||||
|
var items []watchedItem
|
||||||
|
for rows.Next() {
|
||||||
|
var item watchedItem
|
||||||
|
if err := rows.Scan(&item.tmdbID, &item.title, &item.mediaType); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
if item.mediaType != "movie" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
detail, err := p.fetchMovieDetail(item.tmdbID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("movies: daily release fetch", "tmdb_id", item.tmdbID, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if detail.ReleaseDate != today {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find watchers
|
||||||
|
wRows, err := d.Query(
|
||||||
|
`SELECT user_id FROM movie_watchlist WHERE tmdb_id = ?`, item.tmdbID,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("Release day! \"%s\" is out today!\n%s\nhttps://www.themoviedb.org/movie/%d",
|
||||||
|
detail.Title, detail.Tagline, detail.ID)
|
||||||
|
|
||||||
|
for wRows.Next() {
|
||||||
|
var uid string
|
||||||
|
if err := wRows.Scan(&uid); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := p.SendDM(id.UserID(uid), msg); err != nil {
|
||||||
|
slog.Error("movies: DM watcher", "user", uid, "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wRows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
success = true
|
||||||
|
slog.Info("movies: daily releases check completed")
|
||||||
|
}
|
||||||
221
internal/plugin/plugin.go
Normal file
221
internal/plugin/plugin.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/util"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CommandDef describes a bot command for help/discovery.
|
||||||
|
type CommandDef struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Usage string
|
||||||
|
Category string
|
||||||
|
AdminOnly bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageContext holds the context for a message event.
|
||||||
|
type MessageContext struct {
|
||||||
|
RoomID id.RoomID
|
||||||
|
EventID id.EventID
|
||||||
|
Sender id.UserID
|
||||||
|
Body string
|
||||||
|
IsCommand bool // true if the message starts with the command prefix
|
||||||
|
Event *event.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReactionContext holds the context for a reaction event.
|
||||||
|
type ReactionContext struct {
|
||||||
|
RoomID id.RoomID
|
||||||
|
EventID id.EventID
|
||||||
|
Sender id.UserID
|
||||||
|
TargetEvent id.EventID
|
||||||
|
Emoji string
|
||||||
|
Event *event.Event
|
||||||
|
}
|
||||||
|
|
||||||
|
// Plugin is the interface all plugins must implement.
|
||||||
|
type Plugin interface {
|
||||||
|
Name() string
|
||||||
|
Commands() []CommandDef
|
||||||
|
OnMessage(ctx MessageContext) error
|
||||||
|
OnReaction(ctx ReactionContext) error
|
||||||
|
Init() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Base provides common helpers for plugin implementations.
|
||||||
|
type Base struct {
|
||||||
|
Client *mautrix.Client
|
||||||
|
Prefix string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewBase creates a Base with default prefix "!".
|
||||||
|
func NewBase(client *mautrix.Client) Base {
|
||||||
|
return Base{Client: client, Prefix: "!"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCommand checks if body matches prefix+command.
|
||||||
|
func (b *Base) IsCommand(body, command string) bool {
|
||||||
|
return util.IsCommand(body, b.Prefix, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArgs returns the argument string after the command.
|
||||||
|
func (b *Base) GetArgs(body, command string) string {
|
||||||
|
return util.GetArgs(body, b.Prefix, command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsAdmin checks if a user ID is in the admin list.
|
||||||
|
func (b *Base) IsAdmin(userID id.UserID) bool {
|
||||||
|
admins := os.Getenv("ADMIN_USERS")
|
||||||
|
if admins == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, a := range strings.Split(admins, ",") {
|
||||||
|
if strings.TrimSpace(a) == string(userID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessage sends a plain text message to a room.
|
||||||
|
func (b *Base) SendMessage(roomID id.RoomID, text string) error {
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgText,
|
||||||
|
Body: text,
|
||||||
|
}
|
||||||
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to send message", "room", roomID, "err", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendMessageID sends a plain text message and returns the event ID.
|
||||||
|
func (b *Base) SendMessageID(roomID id.RoomID, text string) (id.EventID, error) {
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgText,
|
||||||
|
Body: text,
|
||||||
|
}
|
||||||
|
resp, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to send message", "room", roomID, "err", err)
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return resp.EventID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendThread sends a message in a thread rooted at threadID.
|
||||||
|
func (b *Base) SendThread(roomID id.RoomID, threadID id.EventID, text string) error {
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgText,
|
||||||
|
Body: text,
|
||||||
|
RelatesTo: &event.RelatesTo{
|
||||||
|
Type: event.RelThread,
|
||||||
|
EventID: threadID,
|
||||||
|
InReplyTo: &event.InReplyTo{
|
||||||
|
EventID: threadID,
|
||||||
|
},
|
||||||
|
IsFallingBack: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to send thread message", "room", roomID, "err", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendNotice sends an m.notice message to a room.
|
||||||
|
func (b *Base) SendNotice(roomID id.RoomID, text string) error {
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgNotice,
|
||||||
|
Body: text,
|
||||||
|
}
|
||||||
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendReply sends a reply to a specific event.
|
||||||
|
func (b *Base) SendReply(roomID id.RoomID, eventID id.EventID, text string) error {
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgText,
|
||||||
|
Body: text,
|
||||||
|
RelatesTo: &event.RelatesTo{
|
||||||
|
InReplyTo: &event.InReplyTo{
|
||||||
|
EventID: eventID,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to send reply", "room", roomID, "err", err)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendHTML sends an HTML-formatted message.
|
||||||
|
func (b *Base) SendHTML(roomID id.RoomID, plain, html string) error {
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgText,
|
||||||
|
Body: plain,
|
||||||
|
Format: event.FormatHTML,
|
||||||
|
FormattedBody: html,
|
||||||
|
}
|
||||||
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventMessage, content)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendReact sends a reaction emoji to an event.
|
||||||
|
func (b *Base) SendReact(roomID id.RoomID, eventID id.EventID, emoji string) error {
|
||||||
|
content := &event.ReactionEventContent{
|
||||||
|
RelatesTo: event.RelatesTo{
|
||||||
|
Type: event.RelAnnotation,
|
||||||
|
EventID: eventID,
|
||||||
|
Key: emoji,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := b.Client.SendMessageEvent(context.Background(), roomID, event.EventReaction, content)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendDM sends a direct message to a user. Creates a DM room if needed.
|
||||||
|
func (b *Base) SendDM(userID id.UserID, text string) error {
|
||||||
|
resp, err := b.Client.CreateRoom(context.Background(), &mautrix.ReqCreateRoom{
|
||||||
|
Preset: "trusted_private_chat",
|
||||||
|
Invite: []id.UserID{userID},
|
||||||
|
IsDirect: true,
|
||||||
|
InitialState: []*event.Event{
|
||||||
|
{
|
||||||
|
Type: event.StateEncryption,
|
||||||
|
Content: event.Content{
|
||||||
|
Parsed: &event.EncryptionEventContent{
|
||||||
|
Algorithm: id.AlgorithmMegolmV1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create DM room: %w", err)
|
||||||
|
}
|
||||||
|
return b.SendMessage(resp.RoomID, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
// UploadContent uploads data to the Matrix content repository and returns the MXC URI.
|
||||||
|
func (b *Base) UploadContent(data []byte, contentType, filename string) (id.ContentURI, error) {
|
||||||
|
resp, err := b.Client.UploadBytesWithName(context.Background(), data, contentType, filename)
|
||||||
|
if err != nil {
|
||||||
|
return id.ContentURI{}, err
|
||||||
|
}
|
||||||
|
return resp.ContentURI, nil
|
||||||
|
}
|
||||||
236
internal/plugin/presence.go
Normal file
236
internal/plugin/presence.go
Normal file
@@ -0,0 +1,236 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PresencePlugin handles away status and user profile lookups.
|
||||||
|
type PresencePlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPresencePlugin creates a new PresencePlugin.
|
||||||
|
func NewPresencePlugin(client *mautrix.Client) *PresencePlugin {
|
||||||
|
return &PresencePlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PresencePlugin) Name() string { return "presence" }
|
||||||
|
|
||||||
|
func (p *PresencePlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "away", Description: "Set away status", Usage: "!away [message]", Category: "Personal"},
|
||||||
|
{Name: "afk", Description: "Set away status (alias)", Usage: "!afk [message]", Category: "Personal"},
|
||||||
|
{Name: "back", Description: "Clear away status", Usage: "!back", Category: "Personal"},
|
||||||
|
{Name: "whois", Description: "Show user profile card", Usage: "!whois @user", Category: "Personal"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PresencePlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *PresencePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *PresencePlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
switch {
|
||||||
|
case p.IsCommand(ctx.Body, "away"):
|
||||||
|
return p.handleAway(ctx, p.GetArgs(ctx.Body, "away"))
|
||||||
|
case p.IsCommand(ctx.Body, "afk"):
|
||||||
|
return p.handleAway(ctx, p.GetArgs(ctx.Body, "afk"))
|
||||||
|
case p.IsCommand(ctx.Body, "back"):
|
||||||
|
return p.handleBack(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "whois"):
|
||||||
|
return p.handleWhois(ctx)
|
||||||
|
default:
|
||||||
|
// Auto-clear away status on non-command messages
|
||||||
|
return p.autoClearAway(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PresencePlugin) handleAway(ctx MessageContext, message string) error {
|
||||||
|
if message == "" {
|
||||||
|
message = "Away"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().Unix()
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO presence (user_id, status, message, updated_at)
|
||||||
|
VALUES (?, 'away', ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
status = 'away',
|
||||||
|
message = ?,
|
||||||
|
updated_at = ?`,
|
||||||
|
string(ctx.Sender), message, now, message, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("presence: set away", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to set away status.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("💤 %s is now away: %s", ctx.Sender, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PresencePlugin) handleBack(ctx MessageContext) error {
|
||||||
|
result, err := db.Get().Exec(
|
||||||
|
`UPDATE presence SET status = 'online', message = '', updated_at = ?
|
||||||
|
WHERE user_id = ? AND status = 'away'`,
|
||||||
|
time.Now().Unix(), string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("presence: set back", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to clear away status.")
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return p.SendMessage(ctx.RoomID, "You weren't marked as away.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("👋 Welcome back, %s!", ctx.Sender))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PresencePlugin) autoClearAway(ctx MessageContext) error {
|
||||||
|
// Check if user is away
|
||||||
|
var status string
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT status FROM presence WHERE user_id = ?`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
).Scan(&status)
|
||||||
|
|
||||||
|
if err != nil || status != "away" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear away status
|
||||||
|
_, err = db.Get().Exec(
|
||||||
|
`UPDATE presence SET status = 'online', message = '', updated_at = ?
|
||||||
|
WHERE user_id = ?`,
|
||||||
|
time.Now().Unix(), string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("presence: auto-clear away", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("👋 Welcome back, %s! (auto-cleared away status)", ctx.Sender))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PresencePlugin) handleWhois(ctx MessageContext) error {
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "whois"))
|
||||||
|
if args == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Usage: !whois @user:server")
|
||||||
|
}
|
||||||
|
|
||||||
|
targetUser := id.UserID(args)
|
||||||
|
|
||||||
|
// Gather profile data
|
||||||
|
var displayName string
|
||||||
|
var xp, level int
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT display_name, xp, level FROM users WHERE user_id = ?`,
|
||||||
|
string(targetUser),
|
||||||
|
).Scan(&displayName, &xp, &level)
|
||||||
|
if err != nil {
|
||||||
|
displayName = string(targetUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get stats
|
||||||
|
var totalMessages, totalWords int
|
||||||
|
_ = db.Get().QueryRow(
|
||||||
|
`SELECT total_messages, total_words FROM user_stats WHERE user_id = ?`,
|
||||||
|
string(targetUser),
|
||||||
|
).Scan(&totalMessages, &totalWords)
|
||||||
|
|
||||||
|
// Get streak
|
||||||
|
var currentStreak int
|
||||||
|
today := time.Now().Format("2006-01-02")
|
||||||
|
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||||
|
var countToday, countYesterday int
|
||||||
|
_ = db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM daily_activity WHERE user_id = ? AND date = ?`,
|
||||||
|
string(targetUser), today,
|
||||||
|
).Scan(&countToday)
|
||||||
|
_ = db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM daily_activity WHERE user_id = ? AND date = ?`,
|
||||||
|
string(targetUser), yesterday,
|
||||||
|
).Scan(&countYesterday)
|
||||||
|
if countToday > 0 || countYesterday > 0 {
|
||||||
|
// Count consecutive days backward
|
||||||
|
currentStreak = 0
|
||||||
|
checkDate := time.Now()
|
||||||
|
for {
|
||||||
|
dateStr := checkDate.Format("2006-01-02")
|
||||||
|
var cnt int
|
||||||
|
err := db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM daily_activity WHERE user_id = ? AND date = ?`,
|
||||||
|
string(targetUser), dateStr,
|
||||||
|
).Scan(&cnt)
|
||||||
|
if err != nil || cnt == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
currentStreak++
|
||||||
|
checkDate = checkDate.AddDate(0, 0, -1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get reputation (from XP log with reason = 'rep')
|
||||||
|
var repCount int
|
||||||
|
_ = db.Get().QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM xp_log WHERE user_id = ? AND reason = 'rep'`,
|
||||||
|
string(targetUser),
|
||||||
|
).Scan(&repCount)
|
||||||
|
|
||||||
|
// Get presence status
|
||||||
|
var status, statusMsg string
|
||||||
|
_ = db.Get().QueryRow(
|
||||||
|
`SELECT status, message FROM presence WHERE user_id = ?`,
|
||||||
|
string(targetUser),
|
||||||
|
).Scan(&status, &statusMsg)
|
||||||
|
|
||||||
|
// Get birthday
|
||||||
|
var bdayMonth, bdayDay int
|
||||||
|
var timezone string
|
||||||
|
hasBirthday := false
|
||||||
|
err = db.Get().QueryRow(
|
||||||
|
`SELECT month, day, timezone FROM birthdays WHERE user_id = ?`,
|
||||||
|
string(targetUser),
|
||||||
|
).Scan(&bdayMonth, &bdayDay, &timezone)
|
||||||
|
if err == nil {
|
||||||
|
hasBirthday = true
|
||||||
|
}
|
||||||
|
if timezone == "" {
|
||||||
|
timezone = "Not set"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build profile card
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("👤 **Profile: %s**\n", displayName))
|
||||||
|
sb.WriteString(fmt.Sprintf(" ID: %s\n", targetUser))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Level: %d (XP: %d)\n", level, xp))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Messages: %d | Words: %d\n", totalMessages, totalWords))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Rep: +%d\n", repCount))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Streak: %d days\n", currentStreak))
|
||||||
|
sb.WriteString(fmt.Sprintf(" Timezone: %s\n", timezone))
|
||||||
|
|
||||||
|
if hasBirthday {
|
||||||
|
sb.WriteString(fmt.Sprintf(" Birthday: %s %d\n", time.Month(bdayMonth).String(), bdayDay))
|
||||||
|
}
|
||||||
|
|
||||||
|
if status == "away" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" Status: 💤 Away — %s\n", statusMsg))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(" Status: 🟢 Online\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, sb.String())
|
||||||
|
}
|
||||||
95
internal/plugin/ratelimits.go
Normal file
95
internal/plugin/ratelimits.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RateLimitsPlugin provides rate-limiting utilities for other plugins.
|
||||||
|
type RateLimitsPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRateLimitsPlugin creates a new rate limits plugin.
|
||||||
|
func NewRateLimitsPlugin(client *mautrix.Client) *RateLimitsPlugin {
|
||||||
|
return &RateLimitsPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RateLimitsPlugin) Name() string { return "ratelimits" }
|
||||||
|
|
||||||
|
func (p *RateLimitsPlugin) Commands() []CommandDef { return nil }
|
||||||
|
|
||||||
|
func (p *RateLimitsPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *RateLimitsPlugin) OnMessage(_ MessageContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *RateLimitsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
// CheckLimit returns true if the user is under the rate limit for the given action.
|
||||||
|
// Admin users always bypass rate limits.
|
||||||
|
func (p *RateLimitsPlugin) CheckLimit(userID id.UserID, action string, maxPerDay int) bool {
|
||||||
|
if p.IsAdmin(userID) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
// Atomic check-and-increment: only increment if under limit
|
||||||
|
// This avoids the TOCTOU race between SELECT and UPDATE
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO rate_limits (user_id, action, date, count) VALUES (?, ?, ?, 1)
|
||||||
|
ON CONFLICT(user_id, action, date) DO UPDATE SET count = count + 1
|
||||||
|
WHERE count < ?`,
|
||||||
|
string(userID), action, today, maxPerDay,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("ratelimits: increment", "err", err)
|
||||||
|
return true // Fail open on error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check current count to determine if we're over limit
|
||||||
|
var count int
|
||||||
|
err = d.QueryRow(
|
||||||
|
`SELECT count FROM rate_limits WHERE user_id = ? AND action = ? AND date = ?`,
|
||||||
|
string(userID), action, today,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
return true // Fail open
|
||||||
|
}
|
||||||
|
|
||||||
|
return count <= maxPerDay
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remaining returns how many uses remain for the given action today.
|
||||||
|
// Admin users get maxPerDay (effectively unlimited).
|
||||||
|
func (p *RateLimitsPlugin) Remaining(userID id.UserID, action string, maxPerDay int) int {
|
||||||
|
if p.IsAdmin(userID) {
|
||||||
|
return maxPerDay
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
var count int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT count FROM rate_limits WHERE user_id = ? AND action = ? AND date = ?`,
|
||||||
|
string(userID), action, today,
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
count = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := maxPerDay - count
|
||||||
|
if remaining < 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return remaining
|
||||||
|
}
|
||||||
159
internal/plugin/reactions.go
Normal file
159
internal/plugin/reactions.go
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ReactionsPlugin logs reactions and provides emoji usage statistics.
|
||||||
|
type ReactionsPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReactionsPlugin creates a new reactions plugin.
|
||||||
|
func NewReactionsPlugin(client *mautrix.Client) *ReactionsPlugin {
|
||||||
|
return &ReactionsPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReactionsPlugin) Name() string { return "reactions" }
|
||||||
|
|
||||||
|
func (p *ReactionsPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "emojiboard", Description: "Top emoji givers, receivers, and most used emojis", Usage: "!emojiboard", Category: "Reactions"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReactionsPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *ReactionsPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "emojiboard") {
|
||||||
|
return p.handleEmojiboard(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReactionsPlugin) OnReaction(ctx ReactionContext) error {
|
||||||
|
// Resolve the target event's sender via Matrix API
|
||||||
|
targetUser, err := p.resolveEventSender(ctx.RoomID, ctx.TargetEvent)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reactions: resolve target event sender", "err", err, "event", ctx.TargetEvent)
|
||||||
|
return nil // Don't fail the whole handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't log self-reactions
|
||||||
|
if ctx.Sender == targetUser {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO reaction_log (room_id, event_id, sender, target_user, emoji) VALUES (?, ?, ?, ?, ?)`,
|
||||||
|
string(ctx.RoomID), string(ctx.TargetEvent), string(ctx.Sender), string(targetUser), ctx.Emoji,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reactions: log reaction", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveEventSender fetches an event from the Matrix API to determine its sender.
|
||||||
|
func (p *ReactionsPlugin) resolveEventSender(roomID id.RoomID, eventID id.EventID) (id.UserID, error) {
|
||||||
|
evt, err := p.Client.GetEvent(context.Background(), roomID, eventID)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("get event %s: %w", eventID, err)
|
||||||
|
}
|
||||||
|
return evt.Sender, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReactionsPlugin) handleEmojiboard(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Top 10 emoji givers
|
||||||
|
sb.WriteString("--- Top 10 Emoji Givers ---\n\n")
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT sender, COUNT(*) as cnt FROM reaction_log GROUP BY sender ORDER BY cnt DESC LIMIT 10`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reactions: givers query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||||
|
}
|
||||||
|
giverCount := appendUserBoard(&sb, rows)
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// Top 10 emoji receivers
|
||||||
|
sb.WriteString("\n--- Top 10 Emoji Receivers ---\n\n")
|
||||||
|
rows, err = d.Query(
|
||||||
|
`SELECT target_user, COUNT(*) as cnt FROM reaction_log GROUP BY target_user ORDER BY cnt DESC LIMIT 10`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reactions: receivers query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||||
|
}
|
||||||
|
receiverCount := appendUserBoard(&sb, rows)
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// Top 10 most used emojis
|
||||||
|
sb.WriteString("\n--- Top 10 Most Used Emojis ---\n\n")
|
||||||
|
rows, err = d.Query(
|
||||||
|
`SELECT emoji, COUNT(*) as cnt FROM reaction_log GROUP BY emoji ORDER BY cnt DESC LIMIT 10`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reactions: emoji query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load emojiboard.")
|
||||||
|
}
|
||||||
|
emojiCount := appendEmojiBoard(&sb, rows)
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
if giverCount == 0 && receiverCount == 0 && emojiCount == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No reaction data yet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendUserBoard writes ranked user lines from query rows.
|
||||||
|
func appendUserBoard(sb *strings.Builder, rows *sql.Rows) int {
|
||||||
|
medals := []string{"🥇", "🥈", "🥉"}
|
||||||
|
i := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var name string
|
||||||
|
var cnt int
|
||||||
|
if err := rows.Scan(&name, &cnt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := fmt.Sprintf("#%d", i+1)
|
||||||
|
if i < len(medals) {
|
||||||
|
prefix = medals[i]
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s — %s reactions\n", prefix, name, formatNumber(cnt)))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
|
||||||
|
// appendEmojiBoard writes ranked emoji lines.
|
||||||
|
func appendEmojiBoard(sb *strings.Builder, rows *sql.Rows) int {
|
||||||
|
i := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var emoji string
|
||||||
|
var cnt int
|
||||||
|
if err := rows.Scan(&emoji, &cnt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s — %s times\n", i+1, emoji, formatNumber(cnt)))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
return i
|
||||||
|
}
|
||||||
225
internal/plugin/reminders.go
Normal file
225
internal/plugin/reminders.go
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/olebedev/when"
|
||||||
|
"github.com/olebedev/when/rules/common"
|
||||||
|
"github.com/olebedev/when/rules/en"
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RemindersPlugin handles user reminders with natural language time parsing.
|
||||||
|
type RemindersPlugin struct {
|
||||||
|
Base
|
||||||
|
parser *when.Parser
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRemindersPlugin creates a new RemindersPlugin.
|
||||||
|
func NewRemindersPlugin(client *mautrix.Client) *RemindersPlugin {
|
||||||
|
w := when.New(nil)
|
||||||
|
w.Add(en.All...)
|
||||||
|
w.Add(common.All...)
|
||||||
|
return &RemindersPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
parser: w,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RemindersPlugin) Name() string { return "reminders" }
|
||||||
|
|
||||||
|
func (p *RemindersPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "remindme", Description: "Set a reminder", Usage: "!remindme <time expression> <message>", Category: "Personal"},
|
||||||
|
{Name: "reminders", Description: "List your pending reminders", Usage: "!reminders", Category: "Personal"},
|
||||||
|
{Name: "unremind", Description: "Cancel a reminder", Usage: "!unremind <id>", Category: "Personal"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RemindersPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *RemindersPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *RemindersPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
switch {
|
||||||
|
case p.IsCommand(ctx.Body, "remindme"):
|
||||||
|
return p.handleRemindMe(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "reminders"):
|
||||||
|
return p.handleListReminders(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "unremind"):
|
||||||
|
return p.handleUnremind(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RemindersPlugin) handleRemindMe(ctx MessageContext) error {
|
||||||
|
args := p.GetArgs(ctx.Body, "remindme")
|
||||||
|
if args == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Usage: !remindme <time expression> <message>\nExamples: !remindme in 30 minutes check the oven, !remindme tomorrow at 9am meeting")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.parser.Parse(args, time.Now())
|
||||||
|
if err != nil || result == nil {
|
||||||
|
return p.SendMessage(ctx.RoomID, "I couldn't understand that time expression. Try something like: in 30 minutes, tomorrow at 9am, in 2 hours")
|
||||||
|
}
|
||||||
|
|
||||||
|
fireAt := result.Time
|
||||||
|
if fireAt.Before(time.Now()) {
|
||||||
|
return p.SendMessage(ctx.RoomID, "That time is in the past! Please specify a future time.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract message: everything not part of the time expression
|
||||||
|
message := strings.TrimSpace(args[:result.Index] + args[result.Index+len(result.Text):])
|
||||||
|
if message == "" {
|
||||||
|
message = "Reminder!"
|
||||||
|
}
|
||||||
|
|
||||||
|
reminderID := uuid.New().String()[:8]
|
||||||
|
|
||||||
|
_, err = db.Get().Exec(
|
||||||
|
`INSERT INTO reminders (id, user_id, room_id, message, fire_at, fired)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0)`,
|
||||||
|
reminderID, string(ctx.Sender), string(ctx.RoomID), message, fireAt.Unix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reminders: insert", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to save reminder.")
|
||||||
|
}
|
||||||
|
|
||||||
|
durStr := formatDuration(time.Until(fireAt))
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("⏰ Reminder set! I'll remind you %s (ID: %s)\n\"%s\"", durStr, reminderID, message))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RemindersPlugin) handleListReminders(ctx MessageContext) error {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT id, message, fire_at
|
||||||
|
FROM reminders
|
||||||
|
WHERE user_id = ? AND fired = 0
|
||||||
|
ORDER BY fire_at ASC
|
||||||
|
LIMIT 20`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reminders: query", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to fetch reminders.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("📋 Your pending reminders:\n")
|
||||||
|
found := false
|
||||||
|
for rows.Next() {
|
||||||
|
var reminderID, message string
|
||||||
|
var fireAt int64
|
||||||
|
if err := rows.Scan(&reminderID, &message, &fireAt); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
t := time.Unix(fireAt, 0)
|
||||||
|
sb.WriteString(fmt.Sprintf(" • [%s] %s — %s\n", reminderID, message, t.Format("Jan 2 15:04 MST")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return p.SendMessage(ctx.RoomID, "You have no pending reminders.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RemindersPlugin) handleUnremind(ctx MessageContext) error {
|
||||||
|
reminderID := strings.TrimSpace(p.GetArgs(ctx.Body, "unremind"))
|
||||||
|
if reminderID == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Usage: !unremind <id>")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := db.Get().Exec(
|
||||||
|
`DELETE FROM reminders WHERE id = ? AND user_id = ? AND fired = 0`,
|
||||||
|
reminderID, string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reminders: delete", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to cancel reminder.")
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Reminder not found or already fired.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("🗑️ Reminder %s cancelled.", reminderID))
|
||||||
|
}
|
||||||
|
|
||||||
|
// FirePendingReminders checks for due reminders and sends them. Called by the scheduler.
|
||||||
|
func FirePendingReminders(client *mautrix.Client) {
|
||||||
|
now := time.Now().Unix()
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT id, user_id, room_id, message
|
||||||
|
FROM reminders
|
||||||
|
WHERE fired = 0 AND fire_at <= ?`,
|
||||||
|
now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reminders: query pending", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
base := NewBase(client)
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var reminderID, userID, roomID, message string
|
||||||
|
if err := rows.Scan(&reminderID, &userID, &roomID, &message); err != nil {
|
||||||
|
slog.Error("reminders: scan row", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("⏰ Reminder for %s: %s", userID, message)
|
||||||
|
if err := base.SendMessage(id.RoomID(roomID), msg); err != nil {
|
||||||
|
slog.Error("reminders: send reminder", "err", err, "id", reminderID)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := db.Get().Exec(`UPDATE reminders SET fired = 1 WHERE id = ?`, reminderID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("reminders: mark fired", "err", err, "id", reminderID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatDuration returns a human-readable duration string.
|
||||||
|
func formatDuration(d time.Duration) string {
|
||||||
|
if d < time.Minute {
|
||||||
|
return fmt.Sprintf("in %d seconds", int(d.Seconds()))
|
||||||
|
}
|
||||||
|
if d < time.Hour {
|
||||||
|
mins := int(d.Minutes())
|
||||||
|
if mins == 1 {
|
||||||
|
return "in 1 minute"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("in %d minutes", mins)
|
||||||
|
}
|
||||||
|
if d < 24*time.Hour {
|
||||||
|
hours := int(d.Hours())
|
||||||
|
mins := int(d.Minutes()) % 60
|
||||||
|
if mins == 0 {
|
||||||
|
if hours == 1 {
|
||||||
|
return "in 1 hour"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("in %d hours", hours)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("in %d hours and %d minutes", hours, mins)
|
||||||
|
}
|
||||||
|
days := int(d.Hours()) / 24
|
||||||
|
if days == 1 {
|
||||||
|
return "in 1 day"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("in %d days", days)
|
||||||
|
}
|
||||||
190
internal/plugin/reputation.go
Normal file
190
internal/plugin/reputation.go
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
var thankRe = regexp.MustCompile(`(?i)\b(thanks|thank\s+you|thankyou|thx|ty|tysm|tyvm)\b`)
|
||||||
|
|
||||||
|
// userMentionRe matches Matrix user IDs like @user:server.tld
|
||||||
|
var userMentionRe = regexp.MustCompile(`@[a-zA-Z0-9._=-]+:[a-zA-Z0-9.-]+`)
|
||||||
|
|
||||||
|
// ReputationPlugin tracks gratitude and awards reputation XP.
|
||||||
|
type ReputationPlugin struct {
|
||||||
|
Base
|
||||||
|
xp *XPPlugin
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewReputationPlugin creates a new reputation plugin.
|
||||||
|
func NewReputationPlugin(client *mautrix.Client, xp *XPPlugin) *ReputationPlugin {
|
||||||
|
return &ReputationPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
xp: xp,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReputationPlugin) Name() string { return "reputation" }
|
||||||
|
|
||||||
|
func (p *ReputationPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "rep", Description: "Show reputation count for a user", Usage: "!rep [@user]", Category: "Leveling & Stats"},
|
||||||
|
{Name: "repboard", Description: "Show top 10 reputation receivers", Usage: "!repboard", Category: "Leveling & Stats"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReputationPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *ReputationPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *ReputationPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "rep") {
|
||||||
|
return p.handleRep(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "repboard") {
|
||||||
|
return p.handleRepboard(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive: detect thank-you messages
|
||||||
|
if thankRe.MatchString(ctx.Body) {
|
||||||
|
return p.handleThank(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReputationPlugin) handleThank(ctx MessageContext) error {
|
||||||
|
// Find mentioned users
|
||||||
|
mentions := userMentionRe.FindAllString(ctx.Body, -1)
|
||||||
|
if len(mentions) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
cooldownDuration := int64(24 * 60 * 60) // 24 hours
|
||||||
|
|
||||||
|
for _, mention := range mentions {
|
||||||
|
receiver := id.UserID(mention)
|
||||||
|
|
||||||
|
// Can't thank yourself
|
||||||
|
if receiver == ctx.Sender {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check cooldown
|
||||||
|
var lastGiven int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT last_given FROM rep_cooldowns WHERE giver = ? AND receiver = ?`,
|
||||||
|
string(ctx.Sender), string(receiver),
|
||||||
|
).Scan(&lastGiven)
|
||||||
|
|
||||||
|
if err != nil && err != sql.ErrNoRows {
|
||||||
|
slog.Error("rep: cooldown check", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if err == nil && now-lastGiven < cooldownDuration {
|
||||||
|
continue // Still on cooldown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cooldown
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO rep_cooldowns (giver, receiver, last_given) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(giver, receiver) DO UPDATE SET last_given = ?`,
|
||||||
|
string(ctx.Sender), string(receiver), now, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("rep: update cooldown", "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Award 5 XP via the XP plugin
|
||||||
|
if p.xp != nil {
|
||||||
|
p.xp.GrantXP(receiver, 5, "reputation")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.SendReact(ctx.RoomID, ctx.EventID, "💜"); err != nil {
|
||||||
|
slog.Error("rep: send react", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReputationPlugin) handleRep(ctx MessageContext) error {
|
||||||
|
target := ctx.Sender
|
||||||
|
args := p.GetArgs(ctx.Body, "rep")
|
||||||
|
if args != "" {
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
if cleaned != "" {
|
||||||
|
target = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
var count int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT COALESCE(SUM(amount), 0) FROM xp_log WHERE user_id = ? AND reason = 'reputation'`,
|
||||||
|
string(target),
|
||||||
|
).Scan(&count)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("rep: query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up reputation.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count is total XP from rep, each rep gives 5 XP, so rep count = count / 5
|
||||||
|
repCount := count / 5
|
||||||
|
msg := fmt.Sprintf("💜 %s has received %s reputation points (%s XP from gratitude).",
|
||||||
|
string(target), formatNumber(repCount), formatNumber(count))
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ReputationPlugin) handleRepboard(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT user_id, SUM(amount) as total
|
||||||
|
FROM xp_log WHERE reason = 'reputation'
|
||||||
|
GROUP BY user_id ORDER BY total DESC LIMIT 10`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("rep: repboard query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load reputation board.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("💜 Reputation Board — Top 10\n\n")
|
||||||
|
|
||||||
|
medals := []string{"🥇", "🥈", "🥉"}
|
||||||
|
i := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var totalXP int
|
||||||
|
if err := rows.Scan(&userID, &totalXP); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
repCount := totalXP / 5
|
||||||
|
prefix := fmt.Sprintf("#%d", i+1)
|
||||||
|
if i < len(medals) {
|
||||||
|
prefix = medals[i]
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s — %s rep (%s XP)\n", prefix, userID, formatNumber(repCount), formatNumber(totalXP)))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No reputation data yet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
324
internal/plugin/retro.go
Normal file
324
internal/plugin/retro.go
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// rawgGame represents a game from the RAWG API.
|
||||||
|
type rawgGame struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Slug string `json:"slug"`
|
||||||
|
Released string `json:"released"`
|
||||||
|
Rating float64 `json:"rating"`
|
||||||
|
RatingTop int `json:"rating_top"`
|
||||||
|
Metacritic int `json:"metacritic"`
|
||||||
|
Playtime int `json:"playtime"`
|
||||||
|
Description string `json:"description_raw"`
|
||||||
|
Platforms []struct {
|
||||||
|
Platform struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"platform"`
|
||||||
|
} `json:"platforms"`
|
||||||
|
Genres []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"genres"`
|
||||||
|
Developers []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"developers"`
|
||||||
|
Publishers []struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
} `json:"publishers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// rawgSearchResponse is the RAWG search API response.
|
||||||
|
type rawgSearchResponse struct {
|
||||||
|
Count int `json:"count"`
|
||||||
|
Results []rawgGame `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// retroCacheEntry holds cached retro search data.
|
||||||
|
type retroCacheEntry struct {
|
||||||
|
Results []rawgGame `json:"results"`
|
||||||
|
Detail *rawgGame `json:"detail,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RetroPlugin provides retro game lookups via the RAWG API.
|
||||||
|
type RetroPlugin struct {
|
||||||
|
Base
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRetroPlugin creates a new RetroPlugin.
|
||||||
|
func NewRetroPlugin(client *mautrix.Client) *RetroPlugin {
|
||||||
|
return &RetroPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
apiKey: os.Getenv("RAWG_API_KEY"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RetroPlugin) Name() string { return "retro" }
|
||||||
|
|
||||||
|
func (p *RetroPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "game", Description: "Search for a game", Usage: "!game <query>", Category: "Entertainment"},
|
||||||
|
{Name: "retro", Description: "Search for a game (alias)", Usage: "!retro <query>", Category: "Entertainment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RetroPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *RetroPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *RetroPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "game") {
|
||||||
|
return p.handleSearch(ctx, p.GetArgs(ctx.Body, "game"))
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "retro") {
|
||||||
|
return p.handleSearch(ctx, p.GetArgs(ctx.Body, "retro"))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RetroPlugin) handleSearch(ctx MessageContext, query string) error {
|
||||||
|
if query == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !game <query> or !retro <query>")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.apiKey == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Game lookups are not configured (missing API key).")
|
||||||
|
}
|
||||||
|
|
||||||
|
entry, err := p.fetchGames(query)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("retro: fetch failed", "query", query, "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to search for games.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(entry.Results) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No games found for \"%s\".", query))
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// First result gets detailed info
|
||||||
|
if entry.Detail != nil {
|
||||||
|
sb.WriteString(p.formatGameDetail(entry.Detail))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(p.formatGameBrief(entry.Results[0]))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Additional results (2nd and 3rd) as brief entries
|
||||||
|
limit := 3
|
||||||
|
if len(entry.Results) < limit {
|
||||||
|
limit = len(entry.Results)
|
||||||
|
}
|
||||||
|
if limit > 1 {
|
||||||
|
sb.WriteString("\n\nOther results:\n")
|
||||||
|
for i := 1; i < limit; i++ {
|
||||||
|
sb.WriteString(p.formatGameBrief(entry.Results[i]))
|
||||||
|
if i < limit-1 {
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RetroPlugin) fetchGames(query string) (*retroCacheEntry, error) {
|
||||||
|
d := db.Get()
|
||||||
|
cacheKey := strings.ToLower(query)
|
||||||
|
|
||||||
|
// Check cache (7-day TTL)
|
||||||
|
var cached string
|
||||||
|
var cachedAt int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT data, cached_at FROM retro_cache WHERE search_term = ?`, cacheKey,
|
||||||
|
).Scan(&cached, &cachedAt)
|
||||||
|
if err == nil && time.Now().Unix()-cachedAt < 7*24*3600 {
|
||||||
|
var entry retroCacheEntry
|
||||||
|
if json.Unmarshal([]byte(cached), &entry) == nil {
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search API
|
||||||
|
encoded := url.QueryEscape(query)
|
||||||
|
apiURL := fmt.Sprintf("https://api.rawg.io/api/games?key=%s&search=%s&page_size=3",
|
||||||
|
p.apiKey, encoded)
|
||||||
|
|
||||||
|
var searchResp rawgSearchResponse
|
||||||
|
if err := p.rawgGet(apiURL, &searchResp); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &retroCacheEntry{
|
||||||
|
Results: searchResp.Results,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch detailed info for the first result
|
||||||
|
if len(searchResp.Results) > 0 {
|
||||||
|
detailURL := fmt.Sprintf("https://api.rawg.io/api/games/%d?key=%s",
|
||||||
|
searchResp.Results[0].ID, p.apiKey)
|
||||||
|
var detail rawgGame
|
||||||
|
if err := p.rawgGet(detailURL, &detail); err != nil {
|
||||||
|
slog.Warn("retro: detail fetch failed, using search result", "err", err)
|
||||||
|
} else {
|
||||||
|
entry.Detail = &detail
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
data, _ := json.Marshal(entry)
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO retro_cache (search_term, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(search_term) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
cacheKey, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("retro: cache write", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RetroPlugin) rawgGet(apiURL string, target interface{}) error {
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, apiURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("rawg returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
return json.NewDecoder(resp.Body).Decode(target)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RetroPlugin) formatGameDetail(g *rawgGame) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("%s\n", g.Name))
|
||||||
|
|
||||||
|
if g.Released != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Released: %s\n", g.Released))
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Rating > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Rating: %.2f/5\n", g.Rating))
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Metacritic > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Metacritic: %d\n", g.Metacritic))
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Playtime > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Avg Playtime: %dh\n", g.Playtime))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Platforms) > 0 {
|
||||||
|
platforms := make([]string, 0, len(g.Platforms))
|
||||||
|
for _, pl := range g.Platforms {
|
||||||
|
if pl.Platform.Name != "" {
|
||||||
|
platforms = append(platforms, pl.Platform.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(platforms) > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("Platforms: %s\n", strings.Join(platforms, ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Genres) > 0 {
|
||||||
|
genres := make([]string, len(g.Genres))
|
||||||
|
for i, gen := range g.Genres {
|
||||||
|
genres[i] = gen.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Genres: %s\n", strings.Join(genres, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Developers) > 0 {
|
||||||
|
devs := make([]string, len(g.Developers))
|
||||||
|
for i, d := range g.Developers {
|
||||||
|
devs[i] = d.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Developers: %s\n", strings.Join(devs, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Publishers) > 0 {
|
||||||
|
pubs := make([]string, len(g.Publishers))
|
||||||
|
for i, pub := range g.Publishers {
|
||||||
|
pubs[i] = pub.Name
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Publishers: %s\n", strings.Join(pubs, ", ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Description != "" {
|
||||||
|
desc := g.Description
|
||||||
|
if len(desc) > 400 {
|
||||||
|
desc = desc[:400] + "..."
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s\n", desc))
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Slug != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\nhttps://rawg.io/games/%s", g.Slug))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *RetroPlugin) formatGameBrief(g rawgGame) string {
|
||||||
|
parts := []string{g.Name}
|
||||||
|
|
||||||
|
if g.Released != "" {
|
||||||
|
parts = append(parts, fmt.Sprintf("(%s)", g.Released))
|
||||||
|
}
|
||||||
|
|
||||||
|
if g.Rating > 0 {
|
||||||
|
parts = append(parts, fmt.Sprintf("- %.2f/5", g.Rating))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(g.Platforms) > 0 {
|
||||||
|
platforms := make([]string, 0, len(g.Platforms))
|
||||||
|
for _, pl := range g.Platforms {
|
||||||
|
if pl.Platform.Name != "" {
|
||||||
|
platforms = append(platforms, pl.Platform.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(platforms) > 0 {
|
||||||
|
// Show up to 3 platforms to keep it brief
|
||||||
|
if len(platforms) > 3 {
|
||||||
|
platforms = append(platforms[:3], "...")
|
||||||
|
}
|
||||||
|
parts = append(parts, fmt.Sprintf("[%s]", strings.Join(platforms, ", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return " " + strings.Join(parts, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time interface compliance checks.
|
||||||
|
var _ Plugin = (*RetroPlugin)(nil)
|
||||||
48
internal/plugin/shade.go
Normal file
48
internal/plugin/shade.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ShadePlugin is a stub plugin for future shade/roast features.
|
||||||
|
type ShadePlugin struct {
|
||||||
|
Base
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewShadePlugin creates a new shade plugin.
|
||||||
|
func NewShadePlugin(client *mautrix.Client) *ShadePlugin {
|
||||||
|
return &ShadePlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
enabled: os.Getenv("FEATURE_SHADE") == "true" || os.Getenv("FEATURE_SHADE") == "1",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ShadePlugin) Name() string { return "shade" }
|
||||||
|
|
||||||
|
func (p *ShadePlugin) Commands() []CommandDef {
|
||||||
|
if !p.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "shade", Description: "Throw some shade (coming soon)", Usage: "!shade", Category: "LLM & Sentiment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ShadePlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *ShadePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *ShadePlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.IsCommand(ctx.Body, "shade") {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Coming soon.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
314
internal/plugin/stats.go
Normal file
314
internal/plugin/stats.go
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/util"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
var milestoneThresholds = []int{
|
||||||
|
1_000, 5_000, 10_000, 25_000, 50_000, 100_000, 250_000, 500_000, 1_000_000,
|
||||||
|
}
|
||||||
|
|
||||||
|
// StatsPlugin passively tracks message statistics and provides query commands.
|
||||||
|
type StatsPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStatsPlugin creates a new stats plugin.
|
||||||
|
func NewStatsPlugin(client *mautrix.Client) *StatsPlugin {
|
||||||
|
return &StatsPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsPlugin) Name() string { return "stats" }
|
||||||
|
|
||||||
|
func (p *StatsPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "stats", Description: "Show message statistics for a user", Usage: "!stats [@user]", Category: "Leveling & Stats"},
|
||||||
|
{Name: "rankings", Description: "Show rankings for a stat category", Usage: "!rankings [words|links|questions|emojis]", Category: "Leveling & Stats"},
|
||||||
|
{Name: "personality", Description: "Show your chat personality archetype", Usage: "!personality", Category: "Leveling & Stats"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *StatsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *StatsPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
// Handle commands first
|
||||||
|
if p.IsCommand(ctx.Body, "stats") {
|
||||||
|
return p.handleStats(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "rankings") {
|
||||||
|
return p.handleRankings(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "personality") {
|
||||||
|
return p.handlePersonality(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip tracking for bot commands
|
||||||
|
if ctx.IsCommand {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive: track message stats
|
||||||
|
return p.trackMessage(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsPlugin) trackMessage(ctx MessageContext) error {
|
||||||
|
stats := util.ParseMessage(ctx.Body)
|
||||||
|
d := db.Get()
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
// Determine time-of-day buckets
|
||||||
|
hour := now.Hour()
|
||||||
|
nightIncr := 0
|
||||||
|
morningIncr := 0
|
||||||
|
if hour >= 0 && hour < 6 {
|
||||||
|
nightIncr = 1
|
||||||
|
} else if hour >= 6 && hour < 12 {
|
||||||
|
morningIncr = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO user_stats (user_id, total_messages, total_words, total_chars, total_links,
|
||||||
|
total_images, total_questions, total_exclamations, total_emojis, night_messages, morning_messages, updated_at)
|
||||||
|
VALUES (?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET
|
||||||
|
total_messages = total_messages + 1,
|
||||||
|
total_words = total_words + ?,
|
||||||
|
total_chars = total_chars + ?,
|
||||||
|
total_links = total_links + ?,
|
||||||
|
total_images = total_images + ?,
|
||||||
|
total_questions = total_questions + ?,
|
||||||
|
total_exclamations = total_exclamations + ?,
|
||||||
|
total_emojis = total_emojis + ?,
|
||||||
|
night_messages = night_messages + ?,
|
||||||
|
morning_messages = morning_messages + ?,
|
||||||
|
updated_at = ?`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
stats.Words, stats.Chars, stats.Links, stats.Images,
|
||||||
|
stats.Questions, stats.Exclamations, stats.Emojis,
|
||||||
|
nightIncr, morningIncr, now.Unix(),
|
||||||
|
// ON CONFLICT values
|
||||||
|
stats.Words, stats.Chars, stats.Links, stats.Images,
|
||||||
|
stats.Questions, stats.Exclamations, stats.Emojis,
|
||||||
|
nightIncr, morningIncr, now.Unix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stats: track message", "err", err)
|
||||||
|
return nil // Don't fail the message pipeline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Room milestone tracking
|
||||||
|
p.checkRoomMilestone(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsPlugin) checkRoomMilestone(ctx MessageContext) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Upsert room message count
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO room_milestones (room_id, total_messages, last_milestone)
|
||||||
|
VALUES (?, 1, 0)
|
||||||
|
ON CONFLICT(room_id) DO UPDATE SET total_messages = total_messages + 1`,
|
||||||
|
string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stats: room milestone upsert", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var totalMessages, lastMilestone int
|
||||||
|
err = d.QueryRow(
|
||||||
|
`SELECT total_messages, last_milestone FROM room_milestones WHERE room_id = ?`,
|
||||||
|
string(ctx.RoomID),
|
||||||
|
).Scan(&totalMessages, &lastMilestone)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stats: room milestone read", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we crossed a new milestone
|
||||||
|
for _, threshold := range milestoneThresholds {
|
||||||
|
if totalMessages >= threshold && lastMilestone < threshold {
|
||||||
|
_, err = d.Exec(
|
||||||
|
`UPDATE room_milestones SET last_milestone = ? WHERE room_id = ?`,
|
||||||
|
threshold, string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stats: update milestone", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("🎉 This room just hit %s messages! Keep the conversation going!", formatNumber(threshold))
|
||||||
|
if err := p.SendMessage(ctx.RoomID, msg); err != nil {
|
||||||
|
slog.Error("stats: milestone announcement", "err", err)
|
||||||
|
}
|
||||||
|
return // Only announce one milestone at a time
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsPlugin) handleStats(ctx MessageContext) error {
|
||||||
|
target := ctx.Sender
|
||||||
|
args := p.GetArgs(ctx.Body, "stats")
|
||||||
|
if args != "" {
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
if cleaned != "" {
|
||||||
|
target = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
var totalMsg, totalWords, totalChars, totalLinks, totalImages int
|
||||||
|
var totalQuestions, totalExclamations, totalEmojis, nightMsg, morningMsg int
|
||||||
|
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT total_messages, total_words, total_chars, total_links, total_images,
|
||||||
|
total_questions, total_exclamations, total_emojis, night_messages, morning_messages
|
||||||
|
FROM user_stats WHERE user_id = ?`, string(target),
|
||||||
|
).Scan(&totalMsg, &totalWords, &totalChars, &totalLinks, &totalImages,
|
||||||
|
&totalQuestions, &totalExclamations, &totalEmojis, &nightMsg, &morningMsg)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No stats found for %s.", string(target)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stats: query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up stats.")
|
||||||
|
}
|
||||||
|
|
||||||
|
avgWords := 0
|
||||||
|
if totalMsg > 0 {
|
||||||
|
avgWords = totalWords / totalMsg
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"📊 Stats for %s\n"+
|
||||||
|
"Messages: %s | Words: %s | Chars: %s\n"+
|
||||||
|
"Links: %s | Images: %s | Emojis: %s\n"+
|
||||||
|
"Questions: %s | Exclamations: %s\n"+
|
||||||
|
"Night msgs (00-06): %s | Morning msgs (06-12): %s\n"+
|
||||||
|
"Avg words/msg: %d",
|
||||||
|
string(target),
|
||||||
|
formatNumber(totalMsg), formatNumber(totalWords), formatNumber(totalChars),
|
||||||
|
formatNumber(totalLinks), formatNumber(totalImages), formatNumber(totalEmojis),
|
||||||
|
formatNumber(totalQuestions), formatNumber(totalExclamations),
|
||||||
|
formatNumber(nightMsg), formatNumber(morningMsg),
|
||||||
|
avgWords,
|
||||||
|
)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsPlugin) handleRankings(ctx MessageContext) error {
|
||||||
|
args := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "rankings")))
|
||||||
|
|
||||||
|
column := "total_words"
|
||||||
|
label := "Words"
|
||||||
|
switch args {
|
||||||
|
case "links":
|
||||||
|
column = "total_links"
|
||||||
|
label = "Links"
|
||||||
|
case "questions":
|
||||||
|
column = "total_questions"
|
||||||
|
label = "Questions"
|
||||||
|
case "emojis":
|
||||||
|
column = "total_emojis"
|
||||||
|
label = "Emojis"
|
||||||
|
case "words", "":
|
||||||
|
column = "total_words"
|
||||||
|
label = "Words"
|
||||||
|
default:
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Valid categories: words, links, questions, emojis")
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
// Using fmt.Sprintf for column name is safe here since we control the value above
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
`SELECT user_id, %s FROM user_stats WHERE %s > 0 ORDER BY %s DESC LIMIT 10`,
|
||||||
|
column, column, column,
|
||||||
|
)
|
||||||
|
rows, err := d.Query(query)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stats: rankings query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load rankings.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("📈 %s Rankings — Top 10\n\n", label))
|
||||||
|
|
||||||
|
medals := []string{"🥇", "🥈", "🥉"}
|
||||||
|
i := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var val int
|
||||||
|
if err := rows.Scan(&userID, &val); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := fmt.Sprintf("#%d", i+1)
|
||||||
|
if i < len(medals) {
|
||||||
|
prefix = medals[i]
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s — %s %s\n", prefix, userID, formatNumber(val), strings.ToLower(label)))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("No %s data yet.", strings.ToLower(label)))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StatsPlugin) handlePersonality(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
var totalMsg, totalWords, totalChars, totalLinks, totalImages int
|
||||||
|
var totalQuestions, totalExclamations, totalEmojis int
|
||||||
|
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT total_messages, total_words, total_chars, total_links, total_images,
|
||||||
|
total_questions, total_exclamations, total_emojis
|
||||||
|
FROM user_stats WHERE user_id = ?`, string(ctx.Sender),
|
||||||
|
).Scan(&totalMsg, &totalWords, &totalChars, &totalLinks, &totalImages,
|
||||||
|
&totalQuestions, &totalExclamations, &totalEmojis)
|
||||||
|
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Not enough data to determine your personality yet. Keep chatting!")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stats: personality query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to analyze personality.")
|
||||||
|
}
|
||||||
|
|
||||||
|
msgStats := util.MessageStats{
|
||||||
|
Words: totalWords,
|
||||||
|
Chars: totalChars,
|
||||||
|
Links: totalLinks,
|
||||||
|
Images: totalImages,
|
||||||
|
Questions: totalQuestions,
|
||||||
|
Exclamations: totalExclamations,
|
||||||
|
Emojis: totalEmojis,
|
||||||
|
}
|
||||||
|
|
||||||
|
archetype := util.DeriveArchetype(msgStats, totalMsg)
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"🧠 %s, your chat personality is: **%s**\n_%s_\n\nBased on %s messages analyzed.",
|
||||||
|
string(ctx.Sender), archetype.Name, archetype.Description, formatNumber(totalMsg),
|
||||||
|
)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
344
internal/plugin/stocks.go
Normal file
344
internal/plugin/stocks.go
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// finnhubQuote is the response from the Finnhub quote endpoint.
|
||||||
|
type finnhubQuote struct {
|
||||||
|
Current float64 `json:"c"` // Current price
|
||||||
|
Change float64 `json:"d"` // Change
|
||||||
|
ChangePct float64 `json:"dp"` // Percent change
|
||||||
|
High float64 `json:"h"` // High price of the day
|
||||||
|
Low float64 `json:"l"` // Low price of the day
|
||||||
|
Open float64 `json:"o"` // Open price of the day
|
||||||
|
PrevClose float64 `json:"pc"` // Previous close price
|
||||||
|
Timestamp int64 `json:"t"` // Timestamp
|
||||||
|
}
|
||||||
|
|
||||||
|
// finnhubProfile is the response from the Finnhub company profile endpoint.
|
||||||
|
type finnhubProfile struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Ticker string `json:"ticker"`
|
||||||
|
Exchange string `json:"exchange"`
|
||||||
|
Industry string `json:"finnhubIndustry"`
|
||||||
|
MarketCap float64 `json:"marketCapitalization"` // in millions
|
||||||
|
Currency string `json:"currency"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// stockCacheEntry holds cached stock data.
|
||||||
|
type stockCacheEntry struct {
|
||||||
|
Quote finnhubQuote `json:"quote"`
|
||||||
|
Profile finnhubProfile `json:"profile"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// StocksPlugin provides stock market quotes via Finnhub.
|
||||||
|
type StocksPlugin struct {
|
||||||
|
Base
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStocksPlugin creates a new StocksPlugin.
|
||||||
|
func NewStocksPlugin(client *mautrix.Client) *StocksPlugin {
|
||||||
|
return &StocksPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
apiKey: os.Getenv("FINNHUB_API_KEY"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) Name() string { return "stocks" }
|
||||||
|
|
||||||
|
func (p *StocksPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "stock", Description: "Get stock quote(s)", Usage: "!stock <ticker> [ticker2...]", Category: "Entertainment"},
|
||||||
|
{Name: "stockwatch", Description: "Manage your stock watchlist", Usage: "!stockwatch add|list|remove <ticker>", Category: "Entertainment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *StocksPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *StocksPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "stock") {
|
||||||
|
return p.handleStock(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "stockwatch") {
|
||||||
|
return p.handleStockwatch(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) handleStock(ctx MessageContext) error {
|
||||||
|
args := p.GetArgs(ctx.Body, "stock")
|
||||||
|
if args == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stock <ticker> [ticker2...]")
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.apiKey == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Stock lookups are not configured (missing API key).")
|
||||||
|
}
|
||||||
|
|
||||||
|
tickers := strings.Fields(strings.ToUpper(args))
|
||||||
|
if len(tickers) > 5 {
|
||||||
|
tickers = tickers[:5]
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
for i, ticker := range tickers {
|
||||||
|
if i > 0 {
|
||||||
|
sb.WriteString("\n\n")
|
||||||
|
}
|
||||||
|
entry, err := p.fetchStock(ticker)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stocks: fetch failed", "ticker", ticker, "err", err)
|
||||||
|
sb.WriteString(fmt.Sprintf("%s: Failed to fetch data", ticker))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(p.formatStock(ticker, entry))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) fetchStock(ticker string) (*stockCacheEntry, error) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Check cache (60s TTL)
|
||||||
|
var cached string
|
||||||
|
var cachedAt int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT data, cached_at FROM stocks_cache WHERE ticker = ?`, ticker,
|
||||||
|
).Scan(&cached, &cachedAt)
|
||||||
|
if err == nil && time.Now().Unix()-cachedAt < 60 {
|
||||||
|
var entry stockCacheEntry
|
||||||
|
if json.Unmarshal([]byte(cached), &entry) == nil {
|
||||||
|
return &entry, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch quote
|
||||||
|
quote, err := p.fetchFinnhubQuote(ticker)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fetch quote: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch profile
|
||||||
|
profile, err := p.fetchFinnhubProfile(ticker)
|
||||||
|
if err != nil {
|
||||||
|
slog.Warn("stocks: profile fetch failed, continuing without it", "ticker", ticker, "err", err)
|
||||||
|
profile = &finnhubProfile{}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := &stockCacheEntry{
|
||||||
|
Quote: *quote,
|
||||||
|
Profile: *profile,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache
|
||||||
|
data, _ := json.Marshal(entry)
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO stocks_cache (ticker, data, cached_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(ticker) DO UPDATE SET data = ?, cached_at = ?`,
|
||||||
|
ticker, string(data), time.Now().Unix(), string(data), time.Now().Unix(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stocks: cache write", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return entry, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) fetchFinnhubQuote(ticker string) (*finnhubQuote, error) {
|
||||||
|
url := fmt.Sprintf("https://finnhub.io/api/v1/quote?symbol=%s&token=%s", ticker, p.apiKey)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("finnhub quote returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var quote finnhubQuote
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode("e); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return "e, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) fetchFinnhubProfile(ticker string) (*finnhubProfile, error) {
|
||||||
|
url := fmt.Sprintf("https://finnhub.io/api/v1/stock/profile2?symbol=%s&token=%s", ticker, p.apiKey)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("finnhub profile returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var profile finnhubProfile
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&profile); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &profile, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) formatStock(ticker string, entry *stockCacheEntry) string {
|
||||||
|
q := entry.Quote
|
||||||
|
pr := entry.Profile
|
||||||
|
|
||||||
|
changeSign := ""
|
||||||
|
if q.Change > 0 {
|
||||||
|
changeSign = "+"
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
if pr.Name != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("%s (%s)", pr.Name, ticker))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(ticker)
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("\nPrice: $%.2f", q.Current))
|
||||||
|
sb.WriteString(fmt.Sprintf("\nChange: %s%.2f (%s%.2f%%)", changeSign, q.Change, changeSign, q.ChangePct))
|
||||||
|
sb.WriteString(fmt.Sprintf("\nDay Range: $%.2f - $%.2f", q.Low, q.High))
|
||||||
|
|
||||||
|
if pr.MarketCap > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("\nMarket Cap: %s", formatMarketCap(pr.MarketCap)))
|
||||||
|
}
|
||||||
|
if pr.Exchange != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\nExchange: %s", pr.Exchange))
|
||||||
|
}
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMarketCap(capMillions float64) string {
|
||||||
|
switch {
|
||||||
|
case capMillions >= 1_000_000:
|
||||||
|
return fmt.Sprintf("$%.2fT", capMillions/1_000_000)
|
||||||
|
case capMillions >= 1_000:
|
||||||
|
return fmt.Sprintf("$%.2fB", capMillions/1_000)
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("$%.2fM", capMillions)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) handleStockwatch(ctx MessageContext) error {
|
||||||
|
args := p.GetArgs(ctx.Body, "stockwatch")
|
||||||
|
parts := strings.Fields(args)
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch add|list|remove <ticker>")
|
||||||
|
}
|
||||||
|
|
||||||
|
sub := strings.ToLower(parts[0])
|
||||||
|
switch sub {
|
||||||
|
case "add":
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch add <ticker>")
|
||||||
|
}
|
||||||
|
return p.watchlistAdd(ctx, strings.ToUpper(parts[1]))
|
||||||
|
case "remove":
|
||||||
|
if len(parts) < 2 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch remove <ticker>")
|
||||||
|
}
|
||||||
|
return p.watchlistRemove(ctx, strings.ToUpper(parts[1]))
|
||||||
|
case "list":
|
||||||
|
return p.watchlistList(ctx)
|
||||||
|
default:
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !stockwatch add|list|remove <ticker>")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) watchlistAdd(ctx MessageContext, ticker string) error {
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO stock_watchlist (user_id, ticker, room_id) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id, ticker) DO NOTHING`,
|
||||||
|
string(ctx.Sender), ticker, string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stocks: watchlist add", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to watchlist.")
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Added %s to your watchlist.", ticker))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) watchlistRemove(ctx MessageContext, ticker string) error {
|
||||||
|
d := db.Get()
|
||||||
|
res, err := d.Exec(
|
||||||
|
`DELETE FROM stock_watchlist WHERE user_id = ? AND ticker = ?`,
|
||||||
|
string(ctx.Sender), ticker,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stocks: watchlist remove", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove from watchlist.")
|
||||||
|
}
|
||||||
|
n, _ := res.RowsAffected()
|
||||||
|
if n == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s is not in your watchlist.", ticker))
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Removed %s from your watchlist.", ticker))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StocksPlugin) watchlistList(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT ticker FROM stock_watchlist WHERE user_id = ? ORDER BY ticker`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("stocks: watchlist list", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watchlist.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var tickers []string
|
||||||
|
for rows.Next() {
|
||||||
|
var t string
|
||||||
|
if err := rows.Scan(&t); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
tickers = append(tickers, t)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(tickers) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Your stock watchlist is empty. Use !stockwatch add <ticker> to add stocks.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Your Stock Watchlist:\n")
|
||||||
|
for _, t := range tickers {
|
||||||
|
sb.WriteString(fmt.Sprintf(" - %s\n", t))
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
256
internal/plugin/streaks.go
Normal file
256
internal/plugin/streaks.go
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StreaksPlugin tracks daily activity streaks and first-poster records.
|
||||||
|
type StreaksPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewStreaksPlugin creates a new streaks plugin.
|
||||||
|
func NewStreaksPlugin(client *mautrix.Client) *StreaksPlugin {
|
||||||
|
return &StreaksPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StreaksPlugin) Name() string { return "streaks" }
|
||||||
|
|
||||||
|
func (p *StreaksPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "streak", Description: "Show your current and longest activity streak", Usage: "!streak", Category: "Leveling & Stats"},
|
||||||
|
{Name: "firstboard", Description: "Show top first-posters of the day", Usage: "!firstboard", Category: "Leveling & Stats"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StreaksPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *StreaksPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *StreaksPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "streak") {
|
||||||
|
return p.handleStreak(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "firstboard") {
|
||||||
|
return p.handleFirstboard(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip tracking for bot commands
|
||||||
|
if ctx.IsCommand {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive: track daily activity and first poster
|
||||||
|
p.trackDailyActivity(ctx)
|
||||||
|
p.trackFirstPoster(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StreaksPlugin) trackDailyActivity(ctx MessageContext) {
|
||||||
|
d := db.Get()
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO daily_activity (user_id, date, message_count) VALUES (?, ?, 1)
|
||||||
|
ON CONFLICT(user_id, date) DO UPDATE SET message_count = message_count + 1`,
|
||||||
|
string(ctx.Sender), today,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("streaks: track daily activity", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StreaksPlugin) trackFirstPoster(ctx MessageContext) {
|
||||||
|
d := db.Get()
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
|
||||||
|
// INSERT OR IGNORE — only the first poster for this room+date wins
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT OR IGNORE INTO daily_first (room_id, date, user_id, timestamp) VALUES (?, ?, ?, ?)`,
|
||||||
|
string(ctx.RoomID), today, string(ctx.Sender), now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("streaks: track first poster", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StreaksPlugin) handleStreak(ctx MessageContext) error {
|
||||||
|
target := ctx.Sender
|
||||||
|
args := p.GetArgs(ctx.Body, "streak")
|
||||||
|
if args != "" {
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
if cleaned != "" {
|
||||||
|
target = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Get all active dates for this user, sorted descending
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT date FROM daily_activity WHERE user_id = ? ORDER BY date DESC`,
|
||||||
|
string(target),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("streaks: query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up streak.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var dates []string
|
||||||
|
for rows.Next() {
|
||||||
|
var date string
|
||||||
|
if err := rows.Scan(&date); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
dates = append(dates, date)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(dates) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s has no activity recorded yet.", string(target)))
|
||||||
|
}
|
||||||
|
|
||||||
|
currentStreak, longestStreak := calculateStreaks(dates)
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"🔥 %s — Streak Report\nCurrent streak: %s days\nLongest streak: %s days\nTotal active days: %s",
|
||||||
|
string(target),
|
||||||
|
formatNumber(currentStreak),
|
||||||
|
formatNumber(longestStreak),
|
||||||
|
formatNumber(len(dates)),
|
||||||
|
)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateStreaks computes current and longest streaks from descending-sorted date strings.
|
||||||
|
func calculateStreaks(dates []string) (current int, longest int) {
|
||||||
|
if len(dates) == 0 {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
// Parse first date — current streak only counts if it includes today or yesterday
|
||||||
|
firstDate, err := time.Parse("2006-01-02", dates[0])
|
||||||
|
if err != nil {
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
todayDate, _ := time.Parse("2006-01-02", today)
|
||||||
|
daysSinceFirst := int(todayDate.Sub(firstDate).Hours() / 24)
|
||||||
|
if daysSinceFirst > 1 {
|
||||||
|
// Streak is broken — current streak is 0
|
||||||
|
// Still calculate longest
|
||||||
|
current = 0
|
||||||
|
} else {
|
||||||
|
// Count current streak
|
||||||
|
current = 1
|
||||||
|
for i := 1; i < len(dates); i++ {
|
||||||
|
prev, err1 := time.Parse("2006-01-02", dates[i-1])
|
||||||
|
curr, err2 := time.Parse("2006-01-02", dates[i])
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
diff := int(prev.Sub(curr).Hours() / 24)
|
||||||
|
if diff == 1 {
|
||||||
|
current++
|
||||||
|
} else {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate longest streak (walk all dates)
|
||||||
|
streak := 1
|
||||||
|
longest = 1
|
||||||
|
for i := 1; i < len(dates); i++ {
|
||||||
|
prev, err1 := time.Parse("2006-01-02", dates[i-1])
|
||||||
|
curr, err2 := time.Parse("2006-01-02", dates[i])
|
||||||
|
if err1 != nil || err2 != nil {
|
||||||
|
streak = 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
diff := int(prev.Sub(curr).Hours() / 24)
|
||||||
|
if diff == 1 {
|
||||||
|
streak++
|
||||||
|
} else {
|
||||||
|
streak = 1
|
||||||
|
}
|
||||||
|
if streak > longest {
|
||||||
|
longest = streak
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if current > longest {
|
||||||
|
longest = current
|
||||||
|
}
|
||||||
|
|
||||||
|
return current, longest
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *StreaksPlugin) handleFirstboard(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT user_id, COUNT(*) as wins
|
||||||
|
FROM daily_first
|
||||||
|
GROUP BY user_id
|
||||||
|
ORDER BY wins DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("streaks: firstboard query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load first-poster board.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🌅 First Poster Board — Top 10\n\n")
|
||||||
|
|
||||||
|
medals := []string{"🥇", "🥈", "🥉"}
|
||||||
|
i := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var wins int
|
||||||
|
if err := rows.Scan(&userID, &wins); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := fmt.Sprintf("#%d", i+1)
|
||||||
|
if i < len(medals) {
|
||||||
|
prefix = medals[i]
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s — %s first posts\n", prefix, userID, formatNumber(wins)))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No first-poster data yet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also show today's first poster
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
var todayFirst sql.NullString
|
||||||
|
_ = d.QueryRow(
|
||||||
|
`SELECT user_id FROM daily_first WHERE room_id = ? AND date = ?`,
|
||||||
|
string(ctx.RoomID), today,
|
||||||
|
).Scan(&todayFirst)
|
||||||
|
|
||||||
|
if todayFirst.Valid {
|
||||||
|
sb.WriteString(fmt.Sprintf("\nToday's first poster in this room: %s", todayFirst.String))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
238
internal/plugin/tools.go
Normal file
238
internal/plugin/tools.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/expr-lang/expr"
|
||||||
|
"github.com/skip2/go-qrcode"
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ToolsPlugin provides calculator and QR code generation.
|
||||||
|
type ToolsPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewToolsPlugin creates a new ToolsPlugin.
|
||||||
|
func NewToolsPlugin(client *mautrix.Client) *ToolsPlugin {
|
||||||
|
return &ToolsPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ToolsPlugin) Name() string { return "tools" }
|
||||||
|
|
||||||
|
func (p *ToolsPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "calc", Description: "Math calculator", Usage: "!calc <expression>", Category: "Lookup & Reference"},
|
||||||
|
{Name: "qr", Description: "Generate a QR code", Usage: "!qr <text>", Category: "Lookup & Reference"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ToolsPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *ToolsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *ToolsPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
switch {
|
||||||
|
case p.IsCommand(ctx.Body, "calc"):
|
||||||
|
return p.handleCalc(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "qr"):
|
||||||
|
return p.handleQR(ctx)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ToolsPlugin) handleCalc(ctx MessageContext) error {
|
||||||
|
expression := strings.TrimSpace(p.GetArgs(ctx.Body, "calc"))
|
||||||
|
if expression == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Usage: !calc <expression>\nExamples: !calc 2+2, !calc sqrt(144), !calc 5 times 3")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Normalize natural language
|
||||||
|
normalized := normalizeExpression(expression)
|
||||||
|
|
||||||
|
// Evaluate using expr
|
||||||
|
program, err := expr.Compile(normalized)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("calc: compile error", "expr", normalized, "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Could not parse expression: %s", expression))
|
||||||
|
}
|
||||||
|
|
||||||
|
output, err := expr.Run(program, nil)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("calc: eval error", "expr", normalized, "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("Error evaluating: %s", expression))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, fmt.Sprintf("🧮 %s = **%s**", expression, formatCalcResult(output)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// commaNumberRe matches numbers with commas like 40,000 or 1,234,567.89
|
||||||
|
var commaNumberRe = regexp.MustCompile(`\d{1,3}(,\d{3})+(\.\d+)?`)
|
||||||
|
|
||||||
|
// percentOfRe matches patterns like "8% of 40000" or "15.5% of 200"
|
||||||
|
var percentOfRe = regexp.MustCompile(`(?i)([\d.]+)\s*%\s*of\s+([\d,.]+)`)
|
||||||
|
|
||||||
|
// normalizeExpression converts natural language math to operators.
|
||||||
|
func normalizeExpression(s string) string {
|
||||||
|
// Remove common prefixes
|
||||||
|
s = strings.TrimSpace(s)
|
||||||
|
lower := strings.ToLower(s)
|
||||||
|
for _, prefix := range []string{"what is ", "what's ", "calculate ", "compute ", "evaluate "} {
|
||||||
|
if strings.HasPrefix(lower, prefix) {
|
||||||
|
s = s[len(prefix):]
|
||||||
|
lower = strings.ToLower(s)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip commas from numbers (e.g. 40,000 -> 40000)
|
||||||
|
s = commaNumberRe.ReplaceAllStringFunc(s, func(m string) string {
|
||||||
|
return strings.ReplaceAll(m, ",", "")
|
||||||
|
})
|
||||||
|
|
||||||
|
// Handle "X% of Y" -> (X / 100) * Y
|
||||||
|
s = percentOfRe.ReplaceAllString(s, "($1 / 100) * $2")
|
||||||
|
|
||||||
|
// Replace natural language operators (order matters: longer phrases first)
|
||||||
|
replacements := []struct{ from, to string }{
|
||||||
|
{"divided by", "/"},
|
||||||
|
{"to the power of", "**"},
|
||||||
|
{"raised to", "**"},
|
||||||
|
{"times", "*"},
|
||||||
|
{"multiplied by", "*"},
|
||||||
|
{"plus", "+"},
|
||||||
|
{"minus", "-"},
|
||||||
|
{"mod", "%"},
|
||||||
|
}
|
||||||
|
|
||||||
|
result := s
|
||||||
|
for _, r := range replacements {
|
||||||
|
result = strings.ReplaceAll(strings.ToLower(result), r.from, r.to)
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatCalcResult formats a calculation result, adding commas for large numbers.
|
||||||
|
func formatCalcResult(v interface{}) string {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case int:
|
||||||
|
return formatNumber(n)
|
||||||
|
case int64:
|
||||||
|
return formatNumberInt64(n)
|
||||||
|
case float64:
|
||||||
|
if n == math.Trunc(n) && !math.IsInf(n, 0) && !math.IsNaN(n) && math.Abs(n) < 1e15 {
|
||||||
|
// Whole number - format without decimals
|
||||||
|
return formatNumberInt64(int64(n))
|
||||||
|
}
|
||||||
|
// Format with decimals, then add commas to the integer part
|
||||||
|
formatted := fmt.Sprintf("%g", n)
|
||||||
|
parts := strings.SplitN(formatted, ".", 2)
|
||||||
|
// Try to parse the integer part for comma formatting
|
||||||
|
if len(parts[0]) > 3 {
|
||||||
|
negative := strings.HasPrefix(parts[0], "-")
|
||||||
|
digits := parts[0]
|
||||||
|
if negative {
|
||||||
|
digits = digits[1:]
|
||||||
|
}
|
||||||
|
var result strings.Builder
|
||||||
|
remainder := len(digits) % 3
|
||||||
|
if remainder > 0 {
|
||||||
|
result.WriteString(digits[:remainder])
|
||||||
|
}
|
||||||
|
for i := remainder; i < len(digits); i += 3 {
|
||||||
|
if result.Len() > 0 {
|
||||||
|
result.WriteByte(',')
|
||||||
|
}
|
||||||
|
result.WriteString(digits[i : i+3])
|
||||||
|
}
|
||||||
|
prefix := ""
|
||||||
|
if negative {
|
||||||
|
prefix = "-"
|
||||||
|
}
|
||||||
|
if len(parts) == 2 {
|
||||||
|
return prefix + result.String() + "." + parts[1]
|
||||||
|
}
|
||||||
|
return prefix + result.String()
|
||||||
|
}
|
||||||
|
return formatted
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%v", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatNumberInt64 adds commas to an int64 for display.
|
||||||
|
func formatNumberInt64(n int64) string {
|
||||||
|
if n < 0 {
|
||||||
|
return "-" + formatNumberInt64(-n)
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("%d", n)
|
||||||
|
if len(s) <= 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var result strings.Builder
|
||||||
|
remainder := len(s) % 3
|
||||||
|
if remainder > 0 {
|
||||||
|
result.WriteString(s[:remainder])
|
||||||
|
}
|
||||||
|
for i := remainder; i < len(s); i += 3 {
|
||||||
|
if result.Len() > 0 {
|
||||||
|
result.WriteByte(',')
|
||||||
|
}
|
||||||
|
result.WriteString(s[i : i+3])
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *ToolsPlugin) handleQR(ctx MessageContext) error {
|
||||||
|
text := strings.TrimSpace(p.GetArgs(ctx.Body, "qr"))
|
||||||
|
if text == "" {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Usage: !qr <text or URL>")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(text) > 2048 {
|
||||||
|
return p.SendMessage(ctx.RoomID, "Text is too long for a QR code (max 2048 characters).")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate QR code PNG
|
||||||
|
pngData, err := qrcode.Encode(text, qrcode.Medium, 256)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("qr: generate failed", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to generate QR code.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload to Matrix
|
||||||
|
mxcURI, err := p.UploadContent(pngData, "image/png", "qrcode.png")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("qr: upload failed", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to upload QR code image.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send as image message
|
||||||
|
content := &event.MessageEventContent{
|
||||||
|
MsgType: event.MsgImage,
|
||||||
|
Body: "qrcode.png",
|
||||||
|
URL: id.ContentURIString(mxcURI.String()),
|
||||||
|
Info: &event.FileInfo{
|
||||||
|
MimeType: "image/png",
|
||||||
|
Size: len(pngData),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = p.Client.SendMessageEvent(context.Background(), ctx.RoomID, event.EventMessage, content)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("qr: send image failed", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to send QR code image.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
547
internal/plugin/trivia.go
Normal file
547
internal/plugin/trivia.go
Normal file
@@ -0,0 +1,547 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand/v2"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// categoryMap maps category IDs to human-readable names.
|
||||||
|
var categoryMap = map[int]string{
|
||||||
|
9: "General Knowledge",
|
||||||
|
10: "Books",
|
||||||
|
11: "Film",
|
||||||
|
12: "Music",
|
||||||
|
15: "Video Games",
|
||||||
|
17: "Science & Nature",
|
||||||
|
18: "Computers",
|
||||||
|
21: "Sports",
|
||||||
|
22: "Geography",
|
||||||
|
23: "History",
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryNameToID maps lowercase category names to IDs.
|
||||||
|
var categoryNameToID = map[string]int{
|
||||||
|
"general": 9,
|
||||||
|
"books": 10,
|
||||||
|
"film": 11,
|
||||||
|
"movie": 11,
|
||||||
|
"movies": 11,
|
||||||
|
"music": 12,
|
||||||
|
"games": 15,
|
||||||
|
"gaming": 15,
|
||||||
|
"science": 17,
|
||||||
|
"computers": 18,
|
||||||
|
"tech": 18,
|
||||||
|
"sports": 21,
|
||||||
|
"geography": 22,
|
||||||
|
"geo": 22,
|
||||||
|
"history": 23,
|
||||||
|
}
|
||||||
|
|
||||||
|
type openTDBResponse struct {
|
||||||
|
ResponseCode int `json:"response_code"`
|
||||||
|
Results []openTDBResult `json:"results"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type openTDBResult struct {
|
||||||
|
Category string `json:"category"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
Difficulty string `json:"difficulty"`
|
||||||
|
Question string `json:"question"`
|
||||||
|
CorrectAnswer string `json:"correct_answer"`
|
||||||
|
IncorrectAnswers []string `json:"incorrect_answers"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type activeSession struct {
|
||||||
|
RoomID id.RoomID
|
||||||
|
QuestionEventID id.EventID
|
||||||
|
Question string
|
||||||
|
CorrectAnswer string
|
||||||
|
AllAnswers []string
|
||||||
|
CorrectIndex int
|
||||||
|
StartedAt time.Time
|
||||||
|
Difficulty string
|
||||||
|
Category string
|
||||||
|
}
|
||||||
|
|
||||||
|
// TriviaPlugin handles trivia game sessions.
|
||||||
|
type TriviaPlugin struct {
|
||||||
|
Base
|
||||||
|
mu sync.Mutex
|
||||||
|
sessions map[id.RoomID]*activeSession
|
||||||
|
enabled bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewTriviaPlugin creates a new TriviaPlugin.
|
||||||
|
func NewTriviaPlugin(client *mautrix.Client) *TriviaPlugin {
|
||||||
|
return &TriviaPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
sessions: make(map[id.RoomID]*activeSession),
|
||||||
|
enabled: os.Getenv("FEATURE_TRIVIA") != "false",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) Name() string { return "trivia" }
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "trivia", Description: "Start a trivia question", Usage: "!trivia [category] [easy|medium|hard]", Category: "Fun & Games"},
|
||||||
|
{Name: "trivia scores", Description: "Room trivia leaderboard", Usage: "!trivia scores", Category: "Fun & Games"},
|
||||||
|
{Name: "trivia categories", Description: "List available categories", Usage: "!trivia categories", Category: "Fun & Games"},
|
||||||
|
{Name: "trivia fastest", Description: "Fastest correct answers", Usage: "!trivia fastest", Category: "Fun & Games"},
|
||||||
|
{Name: "trivia stop", Description: "Stop current trivia session", Usage: "!trivia stop", Category: "Fun & Games"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.enabled {
|
||||||
|
if p.IsCommand(ctx.Body, "trivia") {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Trivia is disabled.")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.IsCommand(ctx.Body, "trivia") {
|
||||||
|
args := p.GetArgs(ctx.Body, "trivia")
|
||||||
|
return p.handleTrivia(ctx, args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this is a thread reply to an active trivia question
|
||||||
|
p.mu.Lock()
|
||||||
|
session, ok := p.sessions[ctx.RoomID]
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if ok && session != nil {
|
||||||
|
// Only accept answers in the trivia thread
|
||||||
|
content := ctx.Event.Content.AsMessage()
|
||||||
|
if content != nil && content.RelatesTo != nil &&
|
||||||
|
content.RelatesTo.Type == event.RelThread &&
|
||||||
|
content.RelatesTo.EventID == session.QuestionEventID {
|
||||||
|
return p.handleAnswer(ctx, session)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) handleTrivia(ctx MessageContext, args string) error {
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(args))
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case lower == "scores":
|
||||||
|
return p.showScores(ctx)
|
||||||
|
case lower == "categories":
|
||||||
|
return p.showCategories(ctx)
|
||||||
|
case lower == "fastest":
|
||||||
|
return p.showFastest(ctx)
|
||||||
|
case lower == "stop":
|
||||||
|
return p.stopSession(ctx)
|
||||||
|
default:
|
||||||
|
return p.startQuestion(ctx, args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) showCategories(ctx MessageContext) error {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("📚 Trivia Categories:\n")
|
||||||
|
for catID, name := range categoryMap {
|
||||||
|
sb.WriteString(fmt.Sprintf(" • %s (%d)\n", name, catID))
|
||||||
|
}
|
||||||
|
sb.WriteString("\nUsage: !trivia [category] [easy|medium|hard]")
|
||||||
|
return p.SendMessage(ctx.RoomID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) showScores(ctx MessageContext) error {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT user_id, correct, wrong, total_score
|
||||||
|
FROM trivia_scores
|
||||||
|
WHERE room_id = ?
|
||||||
|
ORDER BY total_score DESC
|
||||||
|
LIMIT 10`,
|
||||||
|
string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("trivia: query scores", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to fetch scores.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🏆 Trivia Leaderboard:\n")
|
||||||
|
rank := 0
|
||||||
|
found := false
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var correct, wrong, totalScore int
|
||||||
|
if err := rows.Scan(&userID, &correct, &wrong, &totalScore); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
rank++
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s — %d pts (%d correct, %d wrong)\n", rank, userID, totalScore, correct, wrong))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return p.SendMessage(ctx.RoomID, "No trivia scores yet in this room!")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) showFastest(ctx MessageContext) error {
|
||||||
|
rows, err := db.Get().Query(
|
||||||
|
`SELECT user_id, fastest_ms
|
||||||
|
FROM trivia_scores
|
||||||
|
WHERE room_id = ? AND fastest_ms > 0
|
||||||
|
ORDER BY fastest_ms ASC
|
||||||
|
LIMIT 10`,
|
||||||
|
string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("trivia: query fastest", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to fetch fastest times.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("⚡ Fastest Correct Answers:\n")
|
||||||
|
rank := 0
|
||||||
|
found := false
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var fastestMs int
|
||||||
|
if err := rows.Scan(&userID, &fastestMs); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
rank++
|
||||||
|
sb.WriteString(fmt.Sprintf("%d. %s — %.2fs\n", rank, userID, float64(fastestMs)/1000.0))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return p.SendMessage(ctx.RoomID, "No fastest times recorded yet!")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) stopSession(ctx MessageContext) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
_, ok := p.sessions[ctx.RoomID]
|
||||||
|
if ok {
|
||||||
|
delete(p.sessions, ctx.RoomID)
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if !ok {
|
||||||
|
return p.SendMessage(ctx.RoomID, "No active trivia session in this room.")
|
||||||
|
}
|
||||||
|
return p.SendMessage(ctx.RoomID, "Trivia session stopped.")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) startQuestion(ctx MessageContext, args string) error {
|
||||||
|
p.mu.Lock()
|
||||||
|
if _, ok := p.sessions[ctx.RoomID]; ok {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return p.SendMessage(ctx.RoomID, "A trivia question is already active! Answer it or use !trivia stop.")
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Parse category and difficulty from args
|
||||||
|
category := 0
|
||||||
|
difficulty := ""
|
||||||
|
parts := strings.Fields(strings.ToLower(args))
|
||||||
|
|
||||||
|
for _, part := range parts {
|
||||||
|
if part == "easy" || part == "medium" || part == "hard" {
|
||||||
|
difficulty = part
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if catID, ok := categoryNameToID[part]; ok {
|
||||||
|
category = catID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build API URL
|
||||||
|
apiURL := "https://opentdb.com/api.php?amount=1"
|
||||||
|
if category > 0 {
|
||||||
|
apiURL += fmt.Sprintf("&category=%d", category)
|
||||||
|
}
|
||||||
|
if difficulty != "" {
|
||||||
|
apiURL += fmt.Sprintf("&difficulty=%s", difficulty)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.Get(apiURL)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("trivia: API request failed", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to fetch trivia question. Try again later.")
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("trivia: read response", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to read trivia response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
var apiResp openTDBResponse
|
||||||
|
if err := json.Unmarshal(body, &apiResp); err != nil {
|
||||||
|
slog.Error("trivia: parse response", "err", err)
|
||||||
|
return p.SendMessage(ctx.RoomID, "Failed to parse trivia response.")
|
||||||
|
}
|
||||||
|
|
||||||
|
if apiResp.ResponseCode != 0 || len(apiResp.Results) == 0 {
|
||||||
|
return p.SendMessage(ctx.RoomID, "No trivia questions available for those criteria. Try different options!")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := apiResp.Results[0]
|
||||||
|
question := html.UnescapeString(result.Question)
|
||||||
|
correctAnswer := html.UnescapeString(result.CorrectAnswer)
|
||||||
|
|
||||||
|
// Build answer list
|
||||||
|
var allAnswers []string
|
||||||
|
correctIndex := 0
|
||||||
|
|
||||||
|
if result.Type == "boolean" {
|
||||||
|
allAnswers = []string{"True", "False"}
|
||||||
|
if correctAnswer == "False" {
|
||||||
|
correctIndex = 1
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Shuffle answers
|
||||||
|
incorrectAnswers := make([]string, len(result.IncorrectAnswers))
|
||||||
|
for i, a := range result.IncorrectAnswers {
|
||||||
|
incorrectAnswers[i] = html.UnescapeString(a)
|
||||||
|
}
|
||||||
|
allAnswers = append(incorrectAnswers, correctAnswer)
|
||||||
|
// Fisher-Yates shuffle
|
||||||
|
for i := len(allAnswers) - 1; i > 0; i-- {
|
||||||
|
j := rand.IntN(i + 1)
|
||||||
|
allAnswers[i], allAnswers[j] = allAnswers[j], allAnswers[i]
|
||||||
|
}
|
||||||
|
for i, a := range allAnswers {
|
||||||
|
if a == correctAnswer {
|
||||||
|
correctIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format question message
|
||||||
|
diffLabel := html.UnescapeString(result.Difficulty)
|
||||||
|
catLabel := html.UnescapeString(result.Category)
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("🧠 **Trivia** [%s / %s]\n\n", catLabel, diffLabel))
|
||||||
|
sb.WriteString(fmt.Sprintf("%s\n\n", question))
|
||||||
|
|
||||||
|
letters := []string{"A", "B", "C", "D"}
|
||||||
|
for i, ans := range allAnswers {
|
||||||
|
if i < len(letters) {
|
||||||
|
sb.WriteString(fmt.Sprintf(" **%s.** %s\n", letters[i], ans))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.WriteString("\nReply with A, B, C, or D (or True/False). You have 30 seconds!")
|
||||||
|
|
||||||
|
msgText := sb.String()
|
||||||
|
|
||||||
|
// Store in DB
|
||||||
|
incorrectJSON, _ := json.Marshal(result.IncorrectAnswers)
|
||||||
|
_, err = db.Get().Exec(
|
||||||
|
`INSERT INTO trivia_sessions (room_id, category, difficulty, question, correct_answer, incorrect_answers, question_type)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
string(ctx.RoomID), category, result.Difficulty, question, correctAnswer, string(incorrectJSON), result.Type,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("trivia: insert session", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send thread root announcement, then post the question in the thread
|
||||||
|
threadRootID, err := p.SendMessageID(ctx.RoomID, "🧠 Trivia time! Reply in thread to answer.")
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := p.SendThread(ctx.RoomID, threadRootID, msgText); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &activeSession{
|
||||||
|
RoomID: ctx.RoomID,
|
||||||
|
QuestionEventID: threadRootID,
|
||||||
|
Question: question,
|
||||||
|
CorrectAnswer: correctAnswer,
|
||||||
|
AllAnswers: allAnswers,
|
||||||
|
CorrectIndex: correctIndex,
|
||||||
|
StartedAt: time.Now(),
|
||||||
|
Difficulty: result.Difficulty,
|
||||||
|
Category: catLabel,
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
p.sessions[ctx.RoomID] = session
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Auto-expire after 30 seconds
|
||||||
|
go func() {
|
||||||
|
time.Sleep(30 * time.Second)
|
||||||
|
p.mu.Lock()
|
||||||
|
current, ok := p.sessions[ctx.RoomID]
|
||||||
|
if ok && current == session {
|
||||||
|
delete(p.sessions, ctx.RoomID)
|
||||||
|
p.mu.Unlock()
|
||||||
|
letters := []string{"A", "B", "C", "D"}
|
||||||
|
ansLetter := ""
|
||||||
|
if session.CorrectIndex < len(letters) {
|
||||||
|
ansLetter = letters[session.CorrectIndex] + ". "
|
||||||
|
}
|
||||||
|
_ = p.SendThread(ctx.RoomID, session.QuestionEventID, fmt.Sprintf("⏰ Time's up! The correct answer was: **%s%s**", ansLetter, session.CorrectAnswer))
|
||||||
|
} else {
|
||||||
|
p.mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *TriviaPlugin) handleAnswer(ctx MessageContext, session *activeSession) error {
|
||||||
|
answer := strings.TrimSpace(strings.ToLower(ctx.Body))
|
||||||
|
|
||||||
|
// Map letter answers to index
|
||||||
|
answerIndex := -1
|
||||||
|
switch answer {
|
||||||
|
case "a":
|
||||||
|
answerIndex = 0
|
||||||
|
case "b":
|
||||||
|
answerIndex = 1
|
||||||
|
case "c":
|
||||||
|
answerIndex = 2
|
||||||
|
case "d":
|
||||||
|
answerIndex = 3
|
||||||
|
case "true":
|
||||||
|
// Find "True" in answers
|
||||||
|
for i, a := range session.AllAnswers {
|
||||||
|
if strings.ToLower(a) == "true" {
|
||||||
|
answerIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case "false":
|
||||||
|
for i, a := range session.AllAnswers {
|
||||||
|
if strings.ToLower(a) == "false" {
|
||||||
|
answerIndex = i
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
// Not a trivia answer, ignore
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if answerIndex < 0 || answerIndex >= len(session.AllAnswers) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
elapsed := time.Since(session.StartedAt)
|
||||||
|
elapsedMs := elapsed.Milliseconds()
|
||||||
|
|
||||||
|
correct := answerIndex == session.CorrectIndex
|
||||||
|
|
||||||
|
// Remove session
|
||||||
|
p.mu.Lock()
|
||||||
|
current, ok := p.sessions[ctx.RoomID]
|
||||||
|
if !ok || current != session {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil // Session already ended
|
||||||
|
}
|
||||||
|
delete(p.sessions, ctx.RoomID)
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
if correct {
|
||||||
|
// Calculate score: 100 at 3s, scaling to 0 at 30s
|
||||||
|
score := calculateScore(elapsed)
|
||||||
|
|
||||||
|
// Update scores in DB
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO trivia_scores (user_id, room_id, correct, wrong, total_score, fastest_ms)
|
||||||
|
VALUES (?, ?, 1, 0, ?, ?)
|
||||||
|
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||||
|
correct = correct + 1,
|
||||||
|
total_score = total_score + ?,
|
||||||
|
fastest_ms = CASE WHEN fastest_ms = 0 OR ? < fastest_ms THEN ? ELSE fastest_ms END`,
|
||||||
|
string(ctx.Sender), string(ctx.RoomID), score, elapsedMs,
|
||||||
|
score, elapsedMs, elapsedMs,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("trivia: update score", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update session record (use subquery since SQLite doesn't support UPDATE...ORDER BY...LIMIT)
|
||||||
|
if _, err := db.Get().Exec(
|
||||||
|
`UPDATE trivia_sessions SET ended = 1, winner_id = ?, winner_time_ms = ?
|
||||||
|
WHERE id = (SELECT id FROM trivia_sessions WHERE room_id = ? AND ended = 0 ORDER BY started_at DESC LIMIT 1)`,
|
||||||
|
string(ctx.Sender), elapsedMs, string(ctx.RoomID),
|
||||||
|
); err != nil {
|
||||||
|
slog.Error("trivia: update session", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendThread(ctx.RoomID, session.QuestionEventID,
|
||||||
|
fmt.Sprintf("✅ Correct! %s answered in %.2fs for %d points!", ctx.Sender, float64(elapsedMs)/1000.0, score))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wrong answer
|
||||||
|
_, err := db.Get().Exec(
|
||||||
|
`INSERT INTO trivia_scores (user_id, room_id, correct, wrong, total_score, fastest_ms)
|
||||||
|
VALUES (?, ?, 0, 1, 0, 0)
|
||||||
|
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
||||||
|
wrong = wrong + 1`,
|
||||||
|
string(ctx.Sender), string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("trivia: update wrong score", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
letters := []string{"A", "B", "C", "D"}
|
||||||
|
ansLetter := ""
|
||||||
|
if session.CorrectIndex < len(letters) {
|
||||||
|
ansLetter = letters[session.CorrectIndex] + ". "
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendThread(ctx.RoomID, session.QuestionEventID,
|
||||||
|
fmt.Sprintf("❌ Wrong! The correct answer was: **%s%s**", ansLetter, session.CorrectAnswer))
|
||||||
|
}
|
||||||
|
|
||||||
|
// calculateScore returns time-weighted points: 100 at 3s, scaling linearly to 0 at 30s.
|
||||||
|
func calculateScore(elapsed time.Duration) int {
|
||||||
|
secs := elapsed.Seconds()
|
||||||
|
if secs <= 3.0 {
|
||||||
|
return 100
|
||||||
|
}
|
||||||
|
if secs >= 30.0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
// Linear interpolation: 100 at 3s -> 0 at 30s
|
||||||
|
score := int(100.0 * (30.0 - secs) / 27.0)
|
||||||
|
if score < 0 {
|
||||||
|
score = 0
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
187
internal/plugin/urls.go
Normal file
187
internal/plugin/urls.go
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"github.com/PuerkitoBio/goquery"
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
var urlRe = regexp.MustCompile(`https?://[^\s<>"]+`)
|
||||||
|
|
||||||
|
// URLsPlugin detects URLs in messages and previews og:title/og:description.
|
||||||
|
type URLsPlugin struct {
|
||||||
|
Base
|
||||||
|
enabled bool
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewURLsPlugin creates a new URL preview plugin.
|
||||||
|
func NewURLsPlugin(client *mautrix.Client) *URLsPlugin {
|
||||||
|
enabled := os.Getenv("FEATURE_URL_PREVIEW") != ""
|
||||||
|
return &URLsPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
enabled: enabled,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 3 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *URLsPlugin) Name() string { return "urls" }
|
||||||
|
|
||||||
|
func (p *URLsPlugin) Commands() []CommandDef {
|
||||||
|
return nil // No commands — purely passive
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *URLsPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *URLsPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *URLsPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if !p.enabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip command messages
|
||||||
|
if ctx.IsCommand {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
urls := urlRe.FindAllString(ctx.Body, 5) // limit to 5 URLs per message
|
||||||
|
if len(urls) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, u := range urls {
|
||||||
|
title, desc, err := p.fetchPreview(u)
|
||||||
|
if err != nil {
|
||||||
|
slog.Debug("urls: fetch preview failed", "url", u, "err", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if title == "" && desc == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
var preview strings.Builder
|
||||||
|
if title != "" {
|
||||||
|
preview.WriteString(fmt.Sprintf("Title: %s", title))
|
||||||
|
}
|
||||||
|
if desc != "" {
|
||||||
|
if preview.Len() > 0 {
|
||||||
|
preview.WriteString("\n")
|
||||||
|
}
|
||||||
|
// Truncate long descriptions
|
||||||
|
if len(desc) > 200 {
|
||||||
|
desc = desc[:200] + "..."
|
||||||
|
}
|
||||||
|
preview.WriteString(fmt.Sprintf("Description: %s", desc))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.SendReply(ctx.RoomID, ctx.EventID, preview.String()); err != nil {
|
||||||
|
slog.Error("urls: send preview", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchPreview retrieves og:title and og:description, checking cache first.
|
||||||
|
func (p *URLsPlugin) fetchPreview(rawURL string) (string, string, error) {
|
||||||
|
d := db.Get()
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
cacheTTL := int64(24 * 60 * 60)
|
||||||
|
|
||||||
|
// Check cache
|
||||||
|
var title, desc string
|
||||||
|
var cachedAt int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT title, description, cached_at FROM url_cache WHERE url = ?`, rawURL,
|
||||||
|
).Scan(&title, &desc, &cachedAt)
|
||||||
|
if err == nil && now-cachedAt < cacheTTL {
|
||||||
|
return title, desc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch from web
|
||||||
|
title, desc, err = p.scrapeOG(rawURL)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
_, cacheErr := d.Exec(
|
||||||
|
`INSERT INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, ?)
|
||||||
|
ON CONFLICT(url) DO UPDATE SET title = ?, description = ?, cached_at = ?`,
|
||||||
|
rawURL, title, desc, now, title, desc, now,
|
||||||
|
)
|
||||||
|
if cacheErr != nil {
|
||||||
|
slog.Error("urls: cache write", "err", cacheErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
return title, desc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// scrapeOG fetches a URL and extracts og:title and og:description.
|
||||||
|
func (p *URLsPlugin) scrapeOG(rawURL string) (string, string, error) {
|
||||||
|
req, err := http.NewRequest("GET", rawURL, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("User-Agent", "GogoBee Bot/1.0")
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("fetch: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return "", "", fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
doc, err := goquery.NewDocumentFromReader(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", fmt.Errorf("parse HTML: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
title := ""
|
||||||
|
desc := ""
|
||||||
|
|
||||||
|
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
|
||||||
|
prop, _ := s.Attr("property")
|
||||||
|
content, _ := s.Attr("content")
|
||||||
|
switch prop {
|
||||||
|
case "og:title":
|
||||||
|
title = content
|
||||||
|
case "og:description":
|
||||||
|
desc = content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fallback to <title> tag if no og:title
|
||||||
|
if title == "" {
|
||||||
|
title = doc.Find("title").First().Text()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to meta description
|
||||||
|
if desc == "" {
|
||||||
|
doc.Find("meta").Each(func(_ int, s *goquery.Selection) {
|
||||||
|
name, _ := s.Attr("name")
|
||||||
|
if strings.EqualFold(name, "description") {
|
||||||
|
content, _ := s.Attr("content")
|
||||||
|
desc = content
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimSpace(title), strings.TrimSpace(desc), nil
|
||||||
|
}
|
||||||
452
internal/plugin/user.go
Normal file
452
internal/plugin/user.go
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// UserPlugin provides personal utility commands: timezone, quotes, now-playing, backlog, keyword watches.
|
||||||
|
type UserPlugin struct {
|
||||||
|
Base
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserPlugin creates a new user plugin.
|
||||||
|
func NewUserPlugin(client *mautrix.Client) *UserPlugin {
|
||||||
|
return &UserPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) Name() string { return "user" }
|
||||||
|
|
||||||
|
func (p *UserPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "settz", Description: "Set your timezone (IANA format)", Usage: "!settz America/New_York", Category: "Personal"},
|
||||||
|
{Name: "mytz", Description: "Show your timezone and current time", Usage: "!mytz", Category: "Personal"},
|
||||||
|
{Name: "timezone", Description: "List common timezones", Usage: "!timezone list", Category: "Personal"},
|
||||||
|
{Name: "quote", Description: "Show a random saved quote from this room", Usage: "!quote", Category: "Personal"},
|
||||||
|
{Name: "np", Description: "Set or show now playing", Usage: "!np [track]", Category: "Personal"},
|
||||||
|
{Name: "backlog", Description: "Manage your personal backlog", Usage: "!backlog add/list/random/done", Category: "Personal"},
|
||||||
|
{Name: "watch", Description: "Watch a keyword for DM alerts", Usage: "!watch <keyword>", Category: "Personal"},
|
||||||
|
{Name: "watching", Description: "List your keyword watches", Usage: "!watching", Category: "Personal"},
|
||||||
|
{Name: "unwatch", Description: "Remove a keyword watch", Usage: "!unwatch <keyword>", Category: "Personal"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *UserPlugin) OnReaction(ctx ReactionContext) error {
|
||||||
|
if ctx.Emoji != "\u2B50" { // ⭐
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch the target event to get the quoted message
|
||||||
|
evt, err := p.Client.GetEvent(context.Background(), ctx.RoomID, ctx.TargetEvent)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: fetch event for quote", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := evt.Content.ParseRaw(evt.Type); err != nil {
|
||||||
|
slog.Error("user: parse event content for quote", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
content := evt.Content.AsMessage()
|
||||||
|
if content == nil || content.Body == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO quotes (room_id, user_id, quote_text, saved_by) VALUES (?, ?, ?, ?)`,
|
||||||
|
string(ctx.RoomID), string(evt.Sender), content.Body, string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: save quote", "err", err)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReact(ctx.RoomID, ctx.EventID, "\u2705") // ✅
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
switch {
|
||||||
|
case p.IsCommand(ctx.Body, "settz"):
|
||||||
|
return p.handleSetTZ(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "mytz"):
|
||||||
|
return p.handleMyTZ(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "timezone"):
|
||||||
|
return p.handleTimezoneList(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "quote"):
|
||||||
|
return p.handleQuote(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "np"):
|
||||||
|
return p.handleNP(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "backlog"):
|
||||||
|
return p.handleBacklog(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "watch"):
|
||||||
|
return p.handleWatch(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "watching"):
|
||||||
|
return p.handleWatching(ctx)
|
||||||
|
case p.IsCommand(ctx.Body, "unwatch"):
|
||||||
|
return p.handleUnwatch(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive: check keyword watches
|
||||||
|
p.checkKeywordWatches(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) handleSetTZ(ctx MessageContext) error {
|
||||||
|
tz := strings.TrimSpace(p.GetArgs(ctx.Body, "settz"))
|
||||||
|
if tz == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !settz <IANA timezone> (e.g. America/New_York)")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := time.LoadLocation(tz)
|
||||||
|
if err != nil {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Invalid timezone: %s. Use IANA format like America/New_York.", tz))
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO birthdays (user_id, month, day, timezone) VALUES (?, 0, 0, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET timezone = ?`,
|
||||||
|
string(ctx.Sender), tz, tz,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: set timezone", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save timezone.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Timezone set to %s.", tz))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) handleMyTZ(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
var tz string
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT timezone FROM birthdays WHERE user_id = ?`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
).Scan(&tz)
|
||||||
|
if err != nil || tz == "" {
|
||||||
|
tz = "UTC"
|
||||||
|
}
|
||||||
|
|
||||||
|
loc, err := time.LoadLocation(tz)
|
||||||
|
if err != nil {
|
||||||
|
loc = time.UTC
|
||||||
|
tz = "UTC"
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
msg := fmt.Sprintf("Your timezone: %s\nCurrent time: %s", tz, now.Format("Monday, January 2, 2006 3:04 PM"))
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) handleTimezoneList(ctx MessageContext) error {
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "timezone"))
|
||||||
|
if args != "list" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
zones := []string{
|
||||||
|
"America/New_York", "America/Chicago", "America/Denver", "America/Los_Angeles",
|
||||||
|
"America/Sao_Paulo", "Europe/London", "Europe/Paris", "Europe/Berlin",
|
||||||
|
"Asia/Tokyo", "Asia/Shanghai", "Asia/Kolkata", "Asia/Seoul",
|
||||||
|
"Australia/Sydney", "Pacific/Auckland", "UTC",
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Common timezones:\n\n")
|
||||||
|
for _, z := range zones {
|
||||||
|
loc, err := time.LoadLocation(z)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
now := time.Now().In(loc)
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s — %s\n", z, now.Format("3:04 PM")))
|
||||||
|
}
|
||||||
|
sb.WriteString("\nSet yours with: !settz <timezone>")
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) handleQuote(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
var quoteText, userID string
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT quote_text, user_id FROM quotes WHERE room_id = ? ORDER BY RANDOM() LIMIT 1`,
|
||||||
|
string(ctx.RoomID),
|
||||||
|
).Scan("eText, &userID)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No quotes saved in this room yet. React with a star to save one!")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: random quote", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch a quote.")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf("\"%s\"\n — %s", quoteText, userID)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) handleNP(ctx MessageContext) error {
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "np"))
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
if args == "" {
|
||||||
|
// Show current now playing
|
||||||
|
var track string
|
||||||
|
var updatedAt int64
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT track, updated_at FROM now_playing WHERE user_id = ?`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
).Scan(&track, &updatedAt)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You don't have anything playing. Use !np <track> to set it.")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: get np", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch now playing.")
|
||||||
|
}
|
||||||
|
t := time.Unix(updatedAt, 0).UTC()
|
||||||
|
msg := fmt.Sprintf("Now playing for %s: %s (set %s)", string(ctx.Sender), track, t.Format("Jan 2 3:04 PM UTC"))
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set now playing
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO now_playing (user_id, track, updated_at) VALUES (?, ?, ?)
|
||||||
|
ON CONFLICT(user_id) DO UPDATE SET track = ?, updated_at = ?`,
|
||||||
|
string(ctx.Sender), args, now, args, now,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: set np", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to save now playing.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Now playing: %s", args))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) handleBacklog(ctx MessageContext) error {
|
||||||
|
args := strings.TrimSpace(p.GetArgs(ctx.Body, "backlog"))
|
||||||
|
if args == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !backlog add <item> | list | random | done <id>")
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
if strings.HasPrefix(strings.ToLower(args), "add ") {
|
||||||
|
item := strings.TrimSpace(args[4:])
|
||||||
|
if item == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Please provide an item to add.")
|
||||||
|
}
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO backlog (user_id, item) VALUES (?, ?)`,
|
||||||
|
string(ctx.Sender), item,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: backlog add", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add to backlog.")
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Added to backlog: %s", item))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(args) == "list" {
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT id, item FROM backlog WHERE user_id = ? AND done = 0 ORDER BY created_at DESC`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: backlog list", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load backlog.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("Your backlog:\n\n")
|
||||||
|
count := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var itemID int
|
||||||
|
var item string
|
||||||
|
if err := rows.Scan(&itemID, &item); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf(" [%d] %s\n", itemID, item))
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
if count == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Your backlog is empty!")
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.ToLower(args) == "random" {
|
||||||
|
var itemID int
|
||||||
|
var item string
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT id, item FROM backlog WHERE user_id = ? AND done = 0 ORDER BY RANDOM() LIMIT 1`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
).Scan(&itemID, &item)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Your backlog is empty!")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: backlog random", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to pick from backlog.")
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Random pick from your backlog: [%d] %s", itemID, item))
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(strings.ToLower(args), "done ") {
|
||||||
|
idStr := strings.TrimSpace(args[5:])
|
||||||
|
result, err := d.Exec(
|
||||||
|
`UPDATE backlog SET done = 1 WHERE id = ? AND user_id = ?`,
|
||||||
|
idStr, string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: backlog done", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to mark as done.")
|
||||||
|
}
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Item not found or not yours.")
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Marked as done!")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !backlog add <item> | list | random | done <id>")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) handleWatch(ctx MessageContext) error {
|
||||||
|
keyword := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "watch")))
|
||||||
|
if keyword == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !watch <keyword>")
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT OR IGNORE INTO keyword_watches (user_id, keyword, room_id) VALUES (?, ?, ?)`,
|
||||||
|
string(ctx.Sender), keyword, string(ctx.RoomID),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: add watch", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to add keyword watch.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Watching for \"%s\". You'll get a DM when it's mentioned.", keyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) handleWatching(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(
|
||||||
|
`SELECT keyword FROM keyword_watches WHERE user_id = ?`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: list watches", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load watches.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var keywords []string
|
||||||
|
for rows.Next() {
|
||||||
|
var kw string
|
||||||
|
if err := rows.Scan(&kw); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
keywords = append(keywords, kw)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(keywords) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "You're not watching any keywords. Use !watch <keyword> to start.")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := "Your keyword watches:\n\n"
|
||||||
|
for _, kw := range keywords {
|
||||||
|
msg += fmt.Sprintf(" - %s\n", kw)
|
||||||
|
}
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) handleUnwatch(ctx MessageContext) error {
|
||||||
|
keyword := strings.ToLower(strings.TrimSpace(p.GetArgs(ctx.Body, "unwatch")))
|
||||||
|
if keyword == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Usage: !unwatch <keyword>")
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
result, err := d.Exec(
|
||||||
|
`DELETE FROM keyword_watches WHERE user_id = ? AND keyword = ?`,
|
||||||
|
string(ctx.Sender), keyword,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: remove watch", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to remove keyword watch.")
|
||||||
|
}
|
||||||
|
|
||||||
|
affected, _ := result.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("You weren't watching \"%s\".", keyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("Stopped watching \"%s\".", keyword))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *UserPlugin) checkKeywordWatches(ctx MessageContext) {
|
||||||
|
// Skip bot commands
|
||||||
|
if ctx.IsCommand {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
bodyLower := strings.ToLower(ctx.Body)
|
||||||
|
|
||||||
|
rows, err := d.Query(`SELECT user_id, keyword FROM keyword_watches WHERE room_id = ?`, string(ctx.RoomID))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("user: check watches", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var watcherID, keyword string
|
||||||
|
if err := rows.Scan(&watcherID, &keyword); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Don't alert the sender about their own messages
|
||||||
|
if id.UserID(watcherID) == ctx.Sender {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(bodyLower, keyword) {
|
||||||
|
preview := ctx.Body
|
||||||
|
if len(preview) > 100 {
|
||||||
|
preview = preview[:100] + "..."
|
||||||
|
}
|
||||||
|
dmMsg := fmt.Sprintf("Keyword alert: \"%s\" was mentioned by %s in %s:\n\n%s",
|
||||||
|
keyword, string(ctx.Sender), string(ctx.RoomID), preview)
|
||||||
|
|
||||||
|
// Use a goroutine to avoid blocking message dispatch
|
||||||
|
go func(uid id.UserID, msg string) {
|
||||||
|
if err := p.SendDM(uid, msg); err != nil {
|
||||||
|
slog.Error("user: send watch DM", "err", err, "user", uid)
|
||||||
|
}
|
||||||
|
}(id.UserID(watcherID), dmMsg)
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
221
internal/plugin/vibe.go
Normal file
221
internal/plugin/vibe.go
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
vibeBufferSize = 50
|
||||||
|
vibeCooldownMins = 5
|
||||||
|
vibeMinMessages = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
type bufferedMessage struct {
|
||||||
|
Sender string
|
||||||
|
Body string
|
||||||
|
Time time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// VibePlugin uses LLM to describe room energy or summarize conversation.
|
||||||
|
type VibePlugin struct {
|
||||||
|
Base
|
||||||
|
mu sync.Mutex
|
||||||
|
buffers map[id.RoomID][]bufferedMessage
|
||||||
|
cooldowns map[id.RoomID]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewVibePlugin creates a new vibe plugin.
|
||||||
|
func NewVibePlugin(client *mautrix.Client) *VibePlugin {
|
||||||
|
return &VibePlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
buffers: make(map[id.RoomID][]bufferedMessage),
|
||||||
|
cooldowns: make(map[id.RoomID]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *VibePlugin) Name() string { return "vibe" }
|
||||||
|
|
||||||
|
func (p *VibePlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "vibe", Description: "LLM describes the current room energy", Usage: "!vibe", Category: "LLM & Sentiment"},
|
||||||
|
{Name: "tldr", Description: "LLM summarizes recent conversation", Usage: "!tldr", Category: "LLM & Sentiment"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *VibePlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *VibePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *VibePlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
// Always buffer messages (except commands)
|
||||||
|
if !ctx.IsCommand {
|
||||||
|
p.addToBuffer(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.IsCommand(ctx.Body, "vibe") {
|
||||||
|
return p.handleVibe(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "tldr") {
|
||||||
|
return p.handleTLDR(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *VibePlugin) addToBuffer(ctx MessageContext) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
buf := p.buffers[ctx.RoomID]
|
||||||
|
buf = append(buf, bufferedMessage{
|
||||||
|
Sender: string(ctx.Sender),
|
||||||
|
Body: ctx.Body,
|
||||||
|
Time: time.Now().UTC(),
|
||||||
|
})
|
||||||
|
|
||||||
|
// Keep only the last N messages
|
||||||
|
if len(buf) > vibeBufferSize {
|
||||||
|
buf = buf[len(buf)-vibeBufferSize:]
|
||||||
|
}
|
||||||
|
|
||||||
|
p.buffers[ctx.RoomID] = buf
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *VibePlugin) getBuffer(roomID id.RoomID) []bufferedMessage {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
buf := p.buffers[roomID]
|
||||||
|
// Return a copy
|
||||||
|
result := make([]bufferedMessage, len(buf))
|
||||||
|
copy(result, buf)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *VibePlugin) checkCooldown(roomID id.RoomID) bool {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
|
||||||
|
last, ok := p.cooldowns[roomID]
|
||||||
|
if ok && time.Since(last) < vibeCooldownMins*time.Minute {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
p.cooldowns[roomID] = time.Now()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *VibePlugin) resetCooldown(roomID id.RoomID) {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
delete(p.cooldowns, roomID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *VibePlugin) handleVibe(ctx MessageContext) error {
|
||||||
|
ollamaHost := os.Getenv("OLLAMA_HOST")
|
||||||
|
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
||||||
|
if ollamaHost == "" || ollamaModel == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := p.getBuffer(ctx.RoomID)
|
||||||
|
if len(buf) < vibeMinMessages {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Need at least %d messages to read the vibe. Currently have %d.", vibeMinMessages, len(buf)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.checkCooldown(ctx.RoomID) {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Vibe check is on cooldown. Try again in %d minutes.", vibeCooldownMins))
|
||||||
|
}
|
||||||
|
|
||||||
|
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||||
|
if botName == "" {
|
||||||
|
botName = "GogoBee"
|
||||||
|
}
|
||||||
|
transcript := formatTranscript(buf)
|
||||||
|
prompt := fmt.Sprintf(
|
||||||
|
`You are %s, a fun community bot. Based on the following recent chat messages, describe the current "vibe" or energy of the room in 2-3 sentences. Be creative, playful, and use colorful language. Reference specific topics or dynamics you notice.
|
||||||
|
|
||||||
|
Recent messages:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Describe the room's current vibe:`, botName, transcript)
|
||||||
|
|
||||||
|
if err := p.SendReply(ctx.RoomID, ctx.EventID, "Reading the room..."); err != nil {
|
||||||
|
slog.Error("vibe: send thinking", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("vibe: ollama call", "err", err)
|
||||||
|
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to read the vibe. LLM might be offline.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *VibePlugin) handleTLDR(ctx MessageContext) error {
|
||||||
|
ollamaHost := os.Getenv("OLLAMA_HOST")
|
||||||
|
ollamaModel := os.Getenv("OLLAMA_MODEL")
|
||||||
|
if ollamaHost == "" || ollamaModel == "" {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "LLM is not configured.")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf := p.getBuffer(ctx.RoomID)
|
||||||
|
if len(buf) < vibeMinMessages {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Need at least %d messages for a summary. Currently have %d.", vibeMinMessages, len(buf)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if !p.checkCooldown(ctx.RoomID) {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("TLDR is on cooldown. Try again in %d minutes.", vibeCooldownMins))
|
||||||
|
}
|
||||||
|
|
||||||
|
tldrBotName := os.Getenv("BOT_DISPLAY_NAME")
|
||||||
|
if tldrBotName == "" {
|
||||||
|
tldrBotName = "GogoBee"
|
||||||
|
}
|
||||||
|
transcript := formatTranscript(buf)
|
||||||
|
prompt := fmt.Sprintf(
|
||||||
|
`You are %s, a community bot. Summarize the following recent chat conversation in 3-5 concise bullet points. Focus on the main topics discussed, any decisions made, and key moments. Be brief and informative.
|
||||||
|
|
||||||
|
Recent messages:
|
||||||
|
%s
|
||||||
|
|
||||||
|
Summary:`, tldrBotName, transcript)
|
||||||
|
|
||||||
|
if err := p.SendReply(ctx.RoomID, ctx.EventID, "Summarizing the conversation..."); err != nil {
|
||||||
|
slog.Error("vibe: send thinking", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := callOllama(ollamaHost, ollamaModel, prompt)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("vibe: ollama call", "err", err)
|
||||||
|
p.resetCooldown(ctx.RoomID) // Don't consume cooldown on failure
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to summarize. LLM might be offline.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendMessage(ctx.RoomID, response)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatTranscript(messages []bufferedMessage) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
for _, m := range messages {
|
||||||
|
// Truncate long messages
|
||||||
|
body := m.Body
|
||||||
|
if len(body) > 300 {
|
||||||
|
body = body[:300] + "..."
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("[%s] %s: %s\n", m.Time.Format("15:04"), m.Sender, body))
|
||||||
|
}
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
206
internal/plugin/welcome.go
Normal file
206
internal/plugin/welcome.go
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WelcomeRegistry is the interface for retrieving all registered commands (for !help).
|
||||||
|
type WelcomeRegistry interface {
|
||||||
|
GetCommands() []CommandDef
|
||||||
|
}
|
||||||
|
|
||||||
|
// WelcomePlugin detects new users and provides the !help command.
|
||||||
|
type WelcomePlugin struct {
|
||||||
|
Base
|
||||||
|
xp *XPPlugin
|
||||||
|
registry WelcomeRegistry
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWelcomePlugin creates a new welcome plugin.
|
||||||
|
func NewWelcomePlugin(client *mautrix.Client, xp *XPPlugin, registry WelcomeRegistry) *WelcomePlugin {
|
||||||
|
return &WelcomePlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
xp: xp,
|
||||||
|
registry: registry,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WelcomePlugin) Name() string { return "welcome" }
|
||||||
|
|
||||||
|
func (p *WelcomePlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "help", Description: "DM you a list of all bot commands", Usage: "!help", Category: "Info"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WelcomePlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *WelcomePlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *WelcomePlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "help") {
|
||||||
|
return p.handleHelp(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive: detect new users by checking for "welcome_wagon" achievement
|
||||||
|
p.checkNewUser(ctx)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WelcomePlugin) checkNewUser(ctx MessageContext) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
var exists int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT 1 FROM achievements WHERE user_id = ? AND achievement_id = 'welcome_wagon'`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
).Scan(&exists)
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
// Already has the achievement, not a new user
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != sql.ErrNoRows {
|
||||||
|
slog.Error("welcome: check achievement", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// New user! Grant achievement
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT OR IGNORE INTO achievements (user_id, achievement_id) VALUES (?, 'welcome_wagon')`,
|
||||||
|
string(ctx.Sender),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("welcome: grant achievement", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant 25 XP
|
||||||
|
if p.xp != nil {
|
||||||
|
p.xp.GrantXP(ctx.Sender, 25, "welcome")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send welcome message
|
||||||
|
botName := os.Getenv("BOT_DISPLAY_NAME")
|
||||||
|
if botName == "" {
|
||||||
|
botName = "GogoBee"
|
||||||
|
}
|
||||||
|
welcome := fmt.Sprintf(
|
||||||
|
"Welcome to the community, %s! I'm %s, your friendly community bot.\n\n"+
|
||||||
|
"Here are some things you can do:\n"+
|
||||||
|
" - !help — see all available commands\n"+
|
||||||
|
" - !rank — check your XP and level\n"+
|
||||||
|
" - !streak — see your activity streak\n"+
|
||||||
|
" - !trivia — play trivia games\n\n"+
|
||||||
|
"You've been awarded 25 XP as a welcome gift! Have fun!",
|
||||||
|
string(ctx.Sender), botName,
|
||||||
|
)
|
||||||
|
if err := p.SendMessage(ctx.RoomID, welcome); err != nil {
|
||||||
|
slog.Error("welcome: send message", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryOrder defines the display order for help categories.
|
||||||
|
var categoryOrder = []string{
|
||||||
|
"Fun & Games",
|
||||||
|
"Leveling & Stats",
|
||||||
|
"Entertainment",
|
||||||
|
"Lookup & Reference",
|
||||||
|
"LLM & Sentiment",
|
||||||
|
"Personal",
|
||||||
|
"Holidays",
|
||||||
|
"Reactions",
|
||||||
|
"Info",
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryEmojis maps categories to display emojis.
|
||||||
|
var categoryEmojis = map[string]string{
|
||||||
|
"Fun & Games": "🎲",
|
||||||
|
"Leveling & Stats": "📊",
|
||||||
|
"Entertainment": "🎬",
|
||||||
|
"Lookup & Reference": "📖",
|
||||||
|
"LLM & Sentiment": "🧠",
|
||||||
|
"Personal": "👤",
|
||||||
|
"Holidays": "🎉",
|
||||||
|
"Reactions": "😎",
|
||||||
|
"Info": "ℹ️",
|
||||||
|
"Admin": "🔧",
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WelcomePlugin) handleHelp(ctx MessageContext) error {
|
||||||
|
cmds := p.registry.GetCommands()
|
||||||
|
if len(cmds) == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No commands available.")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Group by category
|
||||||
|
grouped := make(map[string][]CommandDef)
|
||||||
|
var adminCmds []CommandDef
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
if cmd.AdminOnly {
|
||||||
|
adminCmds = append(adminCmds, cmd)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cat := cmd.Category
|
||||||
|
if cat == "" {
|
||||||
|
cat = "Other"
|
||||||
|
}
|
||||||
|
grouped[cat] = append(grouped[cat], cmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
helpBotName := os.Getenv("BOT_DISPLAY_NAME")
|
||||||
|
if helpBotName == "" {
|
||||||
|
helpBotName = "GogoBee"
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s Commands\n", helpBotName))
|
||||||
|
|
||||||
|
for _, cat := range categoryOrder {
|
||||||
|
cmds, ok := grouped[cat]
|
||||||
|
if !ok || len(cmds) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
emoji := categoryEmojis[cat]
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s %s\n", emoji, cat))
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s — %s\n", cmd.Usage, cmd.Description))
|
||||||
|
}
|
||||||
|
delete(grouped, cat)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Any uncategorized leftovers
|
||||||
|
for cat, cmds := range grouped {
|
||||||
|
if len(cmds) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s\n", cat))
|
||||||
|
for _, cmd := range cmds {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s — %s\n", cmd.Usage, cmd.Description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.IsAdmin(ctx.Sender) && len(adminCmds) > 0 {
|
||||||
|
emoji := categoryEmojis["Admin"]
|
||||||
|
sb.WriteString(fmt.Sprintf("\n%s Admin\n", emoji))
|
||||||
|
for _, cmd := range adminCmds {
|
||||||
|
sb.WriteString(fmt.Sprintf(" %s — %s\n", cmd.Usage, cmd.Description))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DM the help message
|
||||||
|
if err := p.SendDM(ctx.Sender, sb.String()); err != nil {
|
||||||
|
slog.Error("welcome: send help DM", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to send help DM. Do you have DMs enabled?")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Help sent to your DMs!")
|
||||||
|
}
|
||||||
386
internal/plugin/wotd.go
Normal file
386
internal/plugin/wotd.go
Normal file
@@ -0,0 +1,386 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// wordnikWOTDResponse is the top-level Wordnik Word of the Day response.
|
||||||
|
type wordnikWOTDResponse struct {
|
||||||
|
Word string `json:"word"`
|
||||||
|
Definitions []wordnikWOTDDef `json:"definitions"`
|
||||||
|
Examples []wordnikWOTDEx `json:"examples"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wordnikWOTDDef struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
PartOfSpeech string `json:"partOfSpeech"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type wordnikWOTDEx struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WOTDPlugin provides a Word of the Day feature using the Wordnik API.
|
||||||
|
type WOTDPlugin struct {
|
||||||
|
Base
|
||||||
|
apiKey string
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWOTDPlugin creates a new WOTDPlugin.
|
||||||
|
func NewWOTDPlugin(client *mautrix.Client) *WOTDPlugin {
|
||||||
|
return &WOTDPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
apiKey: os.Getenv("WORDNIK_API_KEY"),
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WOTDPlugin) Name() string { return "wotd" }
|
||||||
|
|
||||||
|
func (p *WOTDPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "wotd", Description: "Show today's Word of the Day", Usage: "!wotd", Category: "Lookup & Reference"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WOTDPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *WOTDPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *WOTDPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
if p.IsCommand(ctx.Body, "wotd") {
|
||||||
|
return p.handleWOTD(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive: track WOTD usage in messages
|
||||||
|
if !ctx.IsCommand {
|
||||||
|
p.trackUsage(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prefetch fetches today's Word of the Day from Wordnik and stores it in the database.
|
||||||
|
func (p *WOTDPlugin) Prefetch() error {
|
||||||
|
if p.apiKey == "" {
|
||||||
|
slog.Warn("wotd: WORDNIK_API_KEY not set, skipping prefetch")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
|
// Check if already fetched
|
||||||
|
d := db.Get()
|
||||||
|
var exists int
|
||||||
|
err := d.QueryRow(`SELECT 1 FROM wotd_log WHERE date = ?`, today).Scan(&exists)
|
||||||
|
if err == nil {
|
||||||
|
slog.Info("wotd: already fetched for today", "date", today)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
url := fmt.Sprintf("https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=%s", p.apiKey)
|
||||||
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wotd: create request: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := p.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wotd: fetch: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return fmt.Errorf("wotd: API returned status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var wotd wordnikWOTDResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&wotd); err != nil {
|
||||||
|
return fmt.Errorf("wotd: decode response: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
definition := ""
|
||||||
|
partOfSpeech := ""
|
||||||
|
if len(wotd.Definitions) > 0 {
|
||||||
|
definition = wotd.Definitions[0].Text
|
||||||
|
partOfSpeech = wotd.Definitions[0].PartOfSpeech
|
||||||
|
}
|
||||||
|
|
||||||
|
example := ""
|
||||||
|
if len(wotd.Examples) > 0 {
|
||||||
|
example = wotd.Examples[0].Text
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO wotd_log (date, word, definition, part_of_speech, example, posted)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 0)
|
||||||
|
ON CONFLICT(date) DO NOTHING`,
|
||||||
|
today, wotd.Word, definition, partOfSpeech, example,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wotd: store: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("wotd: prefetched", "date", today, "word", wotd.Word)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// PostWOTD posts today's Word of the Day to the given room and marks it as posted.
|
||||||
|
func (p *WOTDPlugin) PostWOTD(roomID id.RoomID) error {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
var word, definition, partOfSpeech, example string
|
||||||
|
var posted int
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT word, definition, part_of_speech, example, posted FROM wotd_log WHERE date = ?`, today,
|
||||||
|
).Scan(&word, &definition, &partOfSpeech, &example, &posted)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
slog.Warn("wotd: no entry for today, attempting prefetch", "date", today)
|
||||||
|
if err := p.Prefetch(); err != nil {
|
||||||
|
return fmt.Errorf("wotd: prefetch failed: %w", err)
|
||||||
|
}
|
||||||
|
err = d.QueryRow(
|
||||||
|
`SELECT word, definition, part_of_speech, example, posted FROM wotd_log WHERE date = ?`, today,
|
||||||
|
).Scan(&word, &definition, &partOfSpeech, &example, &posted)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("wotd: still no entry after prefetch: %w", err)
|
||||||
|
}
|
||||||
|
} else if err != nil {
|
||||||
|
return fmt.Errorf("wotd: query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if posted == 1 {
|
||||||
|
slog.Info("wotd: already posted today", "date", today)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := p.formatWOTD(word, definition, partOfSpeech, example)
|
||||||
|
if err := p.SendMessage(roomID, msg); err != nil {
|
||||||
|
return fmt.Errorf("wotd: send message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = d.Exec(`UPDATE wotd_log SET posted = 1 WHERE date = ?`, today)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wotd: mark posted", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WOTDPlugin) handleWOTD(ctx MessageContext) error {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
var word, definition, partOfSpeech, example string
|
||||||
|
err := d.QueryRow(
|
||||||
|
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`, today,
|
||||||
|
).Scan(&word, &definition, &partOfSpeech, &example)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// Prefetch on demand
|
||||||
|
if pfErr := p.Prefetch(); pfErr != nil {
|
||||||
|
slog.Error("wotd: on-demand prefetch failed", "err", pfErr)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch Word of the Day. Try again later.")
|
||||||
|
}
|
||||||
|
err = d.QueryRow(
|
||||||
|
`SELECT word, definition, part_of_speech, example FROM wotd_log WHERE date = ?`, today,
|
||||||
|
).Scan(&word, &definition, &partOfSpeech, &example)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wotd: query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to fetch Word of the Day.")
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := p.formatWOTD(word, definition, partOfSpeech, example)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *WOTDPlugin) formatWOTD(word, definition, partOfSpeech, example string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Word of the Day: %s", word))
|
||||||
|
|
||||||
|
if partOfSpeech != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" (%s)", partOfSpeech))
|
||||||
|
}
|
||||||
|
|
||||||
|
if definition != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n\nDefinition: %s", definition))
|
||||||
|
}
|
||||||
|
|
||||||
|
if example != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n\nExample: \"%s\"", example))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString("\n\nUse this word in a message today to earn 25 XP!")
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// trackUsage checks if the user used the WOTD in their message and rewards them.
|
||||||
|
func (p *WOTDPlugin) trackUsage(ctx MessageContext) {
|
||||||
|
today := time.Now().UTC().Format("2006-01-02")
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Get today's word
|
||||||
|
var word string
|
||||||
|
err := d.QueryRow(`SELECT word FROM wotd_log WHERE date = ?`, today).Scan(&word)
|
||||||
|
if err != nil {
|
||||||
|
return // No word today or error; silently skip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if the message contains the word (case-insensitive)
|
||||||
|
if !strings.Contains(strings.ToLower(ctx.Body), strings.ToLower(word)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically insert/increment usage and try to claim reward in one step
|
||||||
|
// This avoids the TOCTOU race where two messages could both grant XP
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO wotd_usage (user_id, date, count, rewarded) VALUES (?, ?, 1, 0)
|
||||||
|
ON CONFLICT(user_id, date) DO UPDATE SET count = count + 1`,
|
||||||
|
string(ctx.Sender), today,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wotd: track usage", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already rewarded before doing LLM verification
|
||||||
|
var rewarded int
|
||||||
|
err = d.QueryRow(
|
||||||
|
`SELECT rewarded FROM wotd_usage WHERE user_id = ? AND date = ?`,
|
||||||
|
string(ctx.Sender), today,
|
||||||
|
).Scan(&rewarded)
|
||||||
|
if err != nil || rewarded == 1 {
|
||||||
|
return // Already rewarded or error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify correct usage with LLM
|
||||||
|
if !p.verifyUsage(word, ctx.Body) {
|
||||||
|
slog.Debug("wotd: LLM rejected usage", "user", ctx.Sender, "word", word)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Atomically claim the reward: only update if not yet rewarded
|
||||||
|
res, err := d.Exec(
|
||||||
|
`UPDATE wotd_usage SET rewarded = 1 WHERE user_id = ? AND date = ? AND rewarded = 0`,
|
||||||
|
string(ctx.Sender), today,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wotd: claim reward", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
affected, _ := res.RowsAffected()
|
||||||
|
if affected == 0 {
|
||||||
|
return // Already rewarded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Grant 25 XP via direct SQL
|
||||||
|
p.grantWOTDXP(ctx.Sender, 25)
|
||||||
|
|
||||||
|
if err := p.SendReply(ctx.RoomID, ctx.EventID,
|
||||||
|
fmt.Sprintf("Nice! You used today's word \"%s\" correctly and earned 25 XP!", word)); err != nil {
|
||||||
|
slog.Error("wotd: send reward message", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// verifyUsage asks the LLM whether the word was used correctly in context.
|
||||||
|
// Returns false if LLM is not configured or on any error.
|
||||||
|
func (p *WOTDPlugin) verifyUsage(word, message string) bool {
|
||||||
|
host := os.Getenv("OLLAMA_HOST")
|
||||||
|
model := os.Getenv("OLLAMA_MODEL")
|
||||||
|
if host == "" || model == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
prompt := fmt.Sprintf(`A user sent this chat message: "%s"
|
||||||
|
|
||||||
|
The Word of the Day is "%s". Was this word used correctly and meaningfully in the message? The user must demonstrate understanding of the word by using it naturally in a sentence — not just mentioning it, quoting it, or saying "the word is X".
|
||||||
|
|
||||||
|
Respond with ONLY "yes" or "no".`, message, word)
|
||||||
|
|
||||||
|
payload := map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": false,
|
||||||
|
}
|
||||||
|
data, err := json.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
apiURL := strings.TrimRight(host, "/") + "/api/generate"
|
||||||
|
slog.Debug("wotd: sending LLM verification request", "url", apiURL, "word", word)
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Post(apiURL, "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wotd: LLM verify request failed", "err", err)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil || resp.StatusCode != http.StatusOK {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
Response string `json:"response"`
|
||||||
|
}
|
||||||
|
if json.Unmarshal(body, &result) != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
answer := strings.ToLower(strings.TrimSpace(result.Response))
|
||||||
|
accepted := strings.HasPrefix(answer, "yes")
|
||||||
|
slog.Debug("wotd: LLM verification", "word", word, "answer", answer, "accepted", accepted)
|
||||||
|
return accepted
|
||||||
|
}
|
||||||
|
|
||||||
|
// grantWOTDXP inserts XP directly via SQL to avoid cross-plugin dependency.
|
||||||
|
func (p *WOTDPlugin) grantWOTDXP(userID id.UserID, amount int) {
|
||||||
|
d := db.Get()
|
||||||
|
|
||||||
|
// Ensure user exists
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO users (user_id, xp, level, last_xp_at) VALUES (?, 0, 0, 0)
|
||||||
|
ON CONFLICT(user_id) DO NOTHING`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wotd: ensure user", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update XP
|
||||||
|
_, err = d.Exec(
|
||||||
|
`UPDATE users SET xp = xp + ?, last_xp_at = ? WHERE user_id = ?`,
|
||||||
|
amount, time.Now().UTC().Unix(), string(userID))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wotd: grant xp", "err", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log XP grant
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||||
|
string(userID), amount, "wotd_usage")
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("wotd: log xp", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
257
internal/plugin/xp.go
Normal file
257
internal/plugin/xp.go
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
package plugin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/util"
|
||||||
|
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
// levelUpMessages are Twinbee/Parodius themed congratulations.
|
||||||
|
var levelUpMessages = []string{
|
||||||
|
"Power-up collected! %s just reached Level %d! 🔔",
|
||||||
|
"Twinbee's bell is ringing! %s ascended to Level %d! 🛎️",
|
||||||
|
"Parodius would be proud — %s hit Level %d! 🐙",
|
||||||
|
"Stage clear! %s leveled up to Level %d! ⭐",
|
||||||
|
"Winbee sends sparkles — %s is now Level %d! ✨",
|
||||||
|
"Bonus round complete! %s reached Level %d! 🎰",
|
||||||
|
"GwinBee detected a power surge — %s is Level %d! ⚡",
|
||||||
|
"The Vic Viper salutes %s for reaching Level %d! 🚀",
|
||||||
|
"Takosuke is dancing! %s just became Level %d! 🎶",
|
||||||
|
"A shower of bells for %s — Level %d unlocked! 🔔🔔🔔",
|
||||||
|
"Pastel confirms: %s has broken through to Level %d! 🌈",
|
||||||
|
"The Shooting Star squadron welcomes %s to Level %d! 💫",
|
||||||
|
}
|
||||||
|
|
||||||
|
// XPPlugin awards XP for messages and tracks levels.
|
||||||
|
type XPPlugin struct {
|
||||||
|
Base
|
||||||
|
mu sync.Mutex
|
||||||
|
cooldowns map[id.UserID]time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewXPPlugin creates a new XP plugin.
|
||||||
|
func NewXPPlugin(client *mautrix.Client) *XPPlugin {
|
||||||
|
return &XPPlugin{
|
||||||
|
Base: NewBase(client),
|
||||||
|
cooldowns: make(map[id.UserID]time.Time),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *XPPlugin) Name() string { return "xp" }
|
||||||
|
|
||||||
|
func (p *XPPlugin) Commands() []CommandDef {
|
||||||
|
return []CommandDef{
|
||||||
|
{Name: "rank", Description: "Show your level, XP, and progress", Usage: "!rank [@user]", Category: "Leveling & Stats"},
|
||||||
|
{Name: "leaderboard", Description: "Show top 10 users by XP", Usage: "!leaderboard", Category: "Leveling & Stats"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *XPPlugin) Init() error { return nil }
|
||||||
|
|
||||||
|
func (p *XPPlugin) OnReaction(_ ReactionContext) error { return nil }
|
||||||
|
|
||||||
|
func (p *XPPlugin) OnMessage(ctx MessageContext) error {
|
||||||
|
// Handle commands
|
||||||
|
if p.IsCommand(ctx.Body, "rank") {
|
||||||
|
return p.handleRank(ctx)
|
||||||
|
}
|
||||||
|
if p.IsCommand(ctx.Body, "leaderboard") {
|
||||||
|
return p.handleLeaderboard(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip XP for commands
|
||||||
|
if ctx.IsCommand {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Passive XP with cooldown
|
||||||
|
p.mu.Lock()
|
||||||
|
last, ok := p.cooldowns[ctx.Sender]
|
||||||
|
now := time.Now().UTC()
|
||||||
|
if ok && now.Sub(last) < 30*time.Second {
|
||||||
|
p.mu.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
p.cooldowns[ctx.Sender] = now
|
||||||
|
// Periodically clean up expired cooldowns to prevent unbounded growth
|
||||||
|
if len(p.cooldowns) > 1000 {
|
||||||
|
for uid, t := range p.cooldowns {
|
||||||
|
if now.Sub(t) > time.Minute {
|
||||||
|
delete(p.cooldowns, uid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
_, leveledUp, newLevel := p.grantXPInternal(ctx.Sender, 10, "message")
|
||||||
|
if leveledUp {
|
||||||
|
msg := levelUpMessages[rand.Intn(len(levelUpMessages))]
|
||||||
|
announcement := fmt.Sprintf(msg, string(ctx.Sender), newLevel)
|
||||||
|
if err := p.SendMessage(ctx.RoomID, announcement); err != nil {
|
||||||
|
slog.Error("failed to send level-up announcement", "err", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GrantXP awards XP to a user from an external plugin and returns (newXP, leveledUp, newLevel).
|
||||||
|
func (p *XPPlugin) GrantXP(userID id.UserID, amount int, reason string) (int, bool, int) {
|
||||||
|
return p.grantXPInternal(userID, amount, reason)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *XPPlugin) grantXPInternal(userID id.UserID, amount int, reason string) (int, bool, int) {
|
||||||
|
d := db.Get()
|
||||||
|
now := time.Now().UTC().Unix()
|
||||||
|
|
||||||
|
// Ensure user row exists
|
||||||
|
_, err := d.Exec(
|
||||||
|
`INSERT INTO users (user_id, xp, level, last_xp_at) VALUES (?, 0, 0, 0)
|
||||||
|
ON CONFLICT(user_id) DO NOTHING`, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("xp: ensure user", "err", err)
|
||||||
|
return 0, false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current state
|
||||||
|
var oldXP, oldLevel int
|
||||||
|
err = d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, string(userID)).Scan(&oldXP, &oldLevel)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("xp: read user", "err", err)
|
||||||
|
return 0, false, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
newXP := oldXP + amount
|
||||||
|
newLevel := util.LevelFromXP(newXP)
|
||||||
|
leveledUp := newLevel > oldLevel
|
||||||
|
|
||||||
|
_, err = d.Exec(
|
||||||
|
`UPDATE users SET xp = ?, level = ?, last_xp_at = ? WHERE user_id = ?`,
|
||||||
|
newXP, newLevel, now, string(userID))
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("xp: update user", "err", err)
|
||||||
|
return oldXP, false, oldLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
// Log the XP grant
|
||||||
|
_, err = d.Exec(
|
||||||
|
`INSERT INTO xp_log (user_id, amount, reason) VALUES (?, ?, ?)`,
|
||||||
|
string(userID), amount, reason)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("xp: log grant", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return newXP, leveledUp, newLevel
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *XPPlugin) handleRank(ctx MessageContext) error {
|
||||||
|
target := ctx.Sender
|
||||||
|
args := p.GetArgs(ctx.Body, "rank")
|
||||||
|
if args != "" {
|
||||||
|
// Trim optional @ prefix and whitespace
|
||||||
|
cleaned := strings.TrimSpace(strings.TrimPrefix(args, "@"))
|
||||||
|
if cleaned != "" {
|
||||||
|
target = id.UserID(cleaned)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d := db.Get()
|
||||||
|
var xp, level int
|
||||||
|
err := d.QueryRow(`SELECT xp, level FROM users WHERE user_id = ?`, string(target)).Scan(&xp, &level)
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, fmt.Sprintf("%s hasn't earned any XP yet.", string(target)))
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("xp: rank query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to look up rank.")
|
||||||
|
}
|
||||||
|
|
||||||
|
nextLevel := level + 1
|
||||||
|
xpForNext := util.XPForLevel(nextLevel)
|
||||||
|
xpForCurrent := util.XPForLevel(level)
|
||||||
|
progress := xp - xpForCurrent
|
||||||
|
needed := xpForNext - xpForCurrent
|
||||||
|
bar := util.ProgressBar(progress, needed, 20)
|
||||||
|
|
||||||
|
// Get rank position
|
||||||
|
var rank int
|
||||||
|
err = d.QueryRow(`SELECT COUNT(*) + 1 FROM users WHERE xp > ?`, xp).Scan(&rank)
|
||||||
|
if err != nil {
|
||||||
|
rank = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"📊 %s — Level %d (Rank #%s)\nXP: %s / %s\n%s",
|
||||||
|
string(target), level, formatNumber(rank),
|
||||||
|
formatNumber(xp), formatNumber(xpForNext), bar,
|
||||||
|
)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *XPPlugin) handleLeaderboard(ctx MessageContext) error {
|
||||||
|
d := db.Get()
|
||||||
|
rows, err := d.Query(`SELECT user_id, xp, level FROM users ORDER BY xp DESC LIMIT 10`)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("xp: leaderboard query", "err", err)
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "Failed to load leaderboard.")
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString("🏆 XP Leaderboard — Top 10\n\n")
|
||||||
|
|
||||||
|
medals := []string{"🥇", "🥈", "🥉"}
|
||||||
|
i := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var userID string
|
||||||
|
var xp, level int
|
||||||
|
if err := rows.Scan(&userID, &xp, &level); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
prefix := fmt.Sprintf("#%d", i+1)
|
||||||
|
if i < len(medals) {
|
||||||
|
prefix = medals[i]
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("%s %s — Level %d (%s XP)\n", prefix, userID, level, formatNumber(xp)))
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
|
||||||
|
if i == 0 {
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, "No XP data yet.")
|
||||||
|
}
|
||||||
|
|
||||||
|
return p.SendReply(ctx.RoomID, ctx.EventID, sb.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatNumber adds commas to an integer for display.
|
||||||
|
func formatNumber(n int) string {
|
||||||
|
if n < 0 {
|
||||||
|
return "-" + formatNumber(-n)
|
||||||
|
}
|
||||||
|
s := fmt.Sprintf("%d", n)
|
||||||
|
if len(s) <= 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
var result strings.Builder
|
||||||
|
remainder := len(s) % 3
|
||||||
|
if remainder > 0 {
|
||||||
|
result.WriteString(s[:remainder])
|
||||||
|
}
|
||||||
|
for i := remainder; i < len(s); i += 3 {
|
||||||
|
if result.Len() > 0 {
|
||||||
|
result.WriteByte(',')
|
||||||
|
}
|
||||||
|
result.WriteString(s[i : i+3])
|
||||||
|
}
|
||||||
|
return result.String()
|
||||||
|
}
|
||||||
86
internal/util/auth.go
Normal file
86
internal/util/auth.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoginResponse holds the Matrix login response fields we care about.
|
||||||
|
type LoginResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
DeviceID string `json:"device_id"`
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoginWithPassword performs a Matrix password login.
|
||||||
|
func LoginWithPassword(homeserver, user, password, deviceDisplayName string) (*LoginResponse, error) {
|
||||||
|
url := homeserver + "/_matrix/client/v3/login"
|
||||||
|
|
||||||
|
body := map[string]any{
|
||||||
|
"type": "m.login.password",
|
||||||
|
"identifier": map[string]string{
|
||||||
|
"type": "m.id.user",
|
||||||
|
"user": user,
|
||||||
|
},
|
||||||
|
"password": password,
|
||||||
|
"initial_device_display_name": deviceDisplayName,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal login body: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 30 * time.Second}
|
||||||
|
resp, err := client.Post(url, "application/json", bytes.NewReader(data))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("login request: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return nil, fmt.Errorf("login failed (HTTP %d): %s", resp.StatusCode, string(respBody))
|
||||||
|
}
|
||||||
|
|
||||||
|
var result LoginResponse
|
||||||
|
if err := json.Unmarshal(respBody, &result); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse login response: %w", err)
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsTokenValid checks if an access token is still valid via /account/whoami.
|
||||||
|
func IsTokenValid(homeserver, accessToken string) (bool, string) {
|
||||||
|
url := homeserver + "/_matrix/client/v3/account/whoami"
|
||||||
|
|
||||||
|
req, err := http.NewRequest("GET", url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+accessToken)
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 10 * time.Second}
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != 200 {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var result struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
if err := json.Unmarshal(body, &result); err != nil {
|
||||||
|
return false, ""
|
||||||
|
}
|
||||||
|
return true, result.UserID
|
||||||
|
}
|
||||||
24
internal/util/logger.go
Normal file
24
internal/util/logger.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
// InitLogger sets up a structured slog logger writing to stderr.
|
||||||
|
func InitLogger(level string) {
|
||||||
|
var lvl slog.Level
|
||||||
|
switch level {
|
||||||
|
case "debug":
|
||||||
|
lvl = slog.LevelDebug
|
||||||
|
case "warn":
|
||||||
|
lvl = slog.LevelWarn
|
||||||
|
case "error":
|
||||||
|
lvl = slog.LevelError
|
||||||
|
default:
|
||||||
|
lvl = slog.LevelInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: lvl})
|
||||||
|
slog.SetDefault(slog.New(handler))
|
||||||
|
}
|
||||||
160
internal/util/parser.go
Normal file
160
internal/util/parser.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"math"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MessageStats holds parsed message metrics.
|
||||||
|
type MessageStats struct {
|
||||||
|
Words int
|
||||||
|
Chars int
|
||||||
|
Links int
|
||||||
|
Images int
|
||||||
|
Questions int
|
||||||
|
Exclamations int
|
||||||
|
Emojis int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
linkRe = regexp.MustCompile(`https?://\S+`)
|
||||||
|
imageRe = regexp.MustCompile(`\.(png|jpg|jpeg|gif|webp|svg|bmp)(\?[^\s]*)?$`)
|
||||||
|
emojiRe = regexp.MustCompile(`[\x{1F600}-\x{1F64F}]|[\x{1F300}-\x{1F5FF}]|[\x{1F680}-\x{1F6FF}]|[\x{1F1E0}-\x{1F1FF}]|[\x{2600}-\x{26FF}]|[\x{2700}-\x{27BF}]`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseMessage extracts stats from a chat message body.
|
||||||
|
func ParseMessage(body string) MessageStats {
|
||||||
|
words := strings.Fields(body)
|
||||||
|
|
||||||
|
links := linkRe.FindAllString(body, -1)
|
||||||
|
images := 0
|
||||||
|
for _, l := range links {
|
||||||
|
if imageRe.MatchString(strings.ToLower(l)) {
|
||||||
|
images++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
questions := strings.Count(body, "?")
|
||||||
|
exclamations := strings.Count(body, "!")
|
||||||
|
emojis := len(emojiRe.FindAllString(body, -1))
|
||||||
|
|
||||||
|
return MessageStats{
|
||||||
|
Words: len(words),
|
||||||
|
Chars: len([]rune(body)),
|
||||||
|
Links: len(links),
|
||||||
|
Images: images,
|
||||||
|
Questions: questions,
|
||||||
|
Exclamations: exclamations,
|
||||||
|
Emojis: emojis,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// XPForLevel returns the cumulative XP needed to reach level n.
|
||||||
|
// Uses quadratic curve: level² × 100.
|
||||||
|
func XPForLevel(level int) int {
|
||||||
|
return level * level * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
// LevelFromXP returns the current level for a given XP total.
|
||||||
|
// Inverse of level² × 100: level = floor(sqrt(xp / 100)).
|
||||||
|
func LevelFromXP(xp int) int {
|
||||||
|
if xp <= 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
level := 0
|
||||||
|
for (level+1)*(level+1)*100 <= xp {
|
||||||
|
level++
|
||||||
|
}
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProgressBar returns a text progress bar like [#####-----] 50%.
|
||||||
|
func ProgressBar(current, max, width int) string {
|
||||||
|
if max <= 0 {
|
||||||
|
return strings.Repeat("-", width)
|
||||||
|
}
|
||||||
|
ratio := float64(current) / float64(max)
|
||||||
|
if ratio > 1 {
|
||||||
|
ratio = 1
|
||||||
|
}
|
||||||
|
filled := int(math.Round(ratio * float64(width)))
|
||||||
|
if filled > width {
|
||||||
|
filled = width
|
||||||
|
}
|
||||||
|
empty := width - filled
|
||||||
|
pct := int(ratio * 100)
|
||||||
|
|
||||||
|
return "[" + strings.Repeat("#", filled) + strings.Repeat("-", empty) + "] " + strconv.Itoa(pct) + "%"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Archetype definitions matching the TS version.
|
||||||
|
type Archetype struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
}
|
||||||
|
|
||||||
|
var archetypes = []struct {
|
||||||
|
name string
|
||||||
|
desc string
|
||||||
|
check func(s MessageStats, totalMessages int) bool
|
||||||
|
}{
|
||||||
|
{"Chatterbox", "You talk a LOT", func(s MessageStats, t int) bool { return t > 500 && s.Words > 0 }},
|
||||||
|
{"Novelist", "Long-form writer", func(s MessageStats, t int) bool { return s.Words > 0 && s.Chars/max1(s.Words) > 8 }},
|
||||||
|
{"Inquisitor", "Always asking questions", func(s MessageStats, t int) bool { return s.Questions > t/5 && s.Questions > 10 }},
|
||||||
|
{"Linkmaster", "Shares lots of links", func(s MessageStats, t int) bool { return s.Links > t/10 && s.Links > 5 }},
|
||||||
|
{"Shutterbug", "Posts lots of images", func(s MessageStats, t int) bool { return s.Images > t/10 && s.Images > 5 }},
|
||||||
|
{"Enthusiast", "Lots of exclamation marks!", func(s MessageStats, t int) bool { return s.Exclamations > t/4 && s.Exclamations > 10 }},
|
||||||
|
{"Regular", "A steady community member", func(_ MessageStats, _ int) bool { return true }},
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeriveArchetype picks the best-fitting archetype based on aggregate stats.
|
||||||
|
func DeriveArchetype(stats MessageStats, totalMessages int) Archetype {
|
||||||
|
for _, a := range archetypes {
|
||||||
|
if a.check(stats, totalMessages) {
|
||||||
|
return Archetype{Name: a.name, Description: a.desc}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Archetype{Name: "Regular", Description: "A steady community member"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func max1(n int) int {
|
||||||
|
if n < 1 {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsCommand checks if body starts with prefix+command (case-insensitive).
|
||||||
|
func IsCommand(body, prefix, command string) bool {
|
||||||
|
cmd := prefix + command
|
||||||
|
lower := strings.ToLower(strings.TrimSpace(body))
|
||||||
|
if lower == cmd {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return strings.HasPrefix(lower, cmd+" ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetArgs returns everything after the command prefix.
|
||||||
|
func GetArgs(body, prefix, command string) string {
|
||||||
|
cmd := prefix + command
|
||||||
|
trimmed := strings.TrimSpace(body)
|
||||||
|
lower := strings.ToLower(trimmed)
|
||||||
|
if !strings.HasPrefix(lower, cmd) {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
rest := trimmed[len(cmd):]
|
||||||
|
return strings.TrimSpace(rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasNonASCII checks if string contains non-ASCII characters.
|
||||||
|
func HasNonASCII(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > unicode.MaxASCII {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
359
main.go
Normal file
359
main.go
Normal file
@@ -0,0 +1,359 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"os/signal"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
|
||||||
|
"gogobee/internal/bot"
|
||||||
|
"gogobee/internal/db"
|
||||||
|
"gogobee/internal/plugin"
|
||||||
|
"gogobee/internal/util"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
"github.com/robfig/cron/v3"
|
||||||
|
"maunium.net/go/mautrix"
|
||||||
|
"maunium.net/go/mautrix/event"
|
||||||
|
"maunium.net/go/mautrix/id"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
if err := godotenv.Load(); err != nil {
|
||||||
|
// Log before structured logger is set up
|
||||||
|
fmt.Fprintf(os.Stderr, "WARNING: could not load .env file: %v\n", err)
|
||||||
|
fmt.Fprintf(os.Stderr, " (working directory: %s)\n", mustGetwd())
|
||||||
|
}
|
||||||
|
|
||||||
|
logLevel := os.Getenv("LOG_LEVEL")
|
||||||
|
if logLevel == "" {
|
||||||
|
logLevel = "info"
|
||||||
|
}
|
||||||
|
util.InitLogger(logLevel)
|
||||||
|
slog.Info("logger initialized", "level", logLevel,
|
||||||
|
"ollama_host", os.Getenv("OLLAMA_HOST"),
|
||||||
|
"ollama_model", os.Getenv("OLLAMA_MODEL"))
|
||||||
|
|
||||||
|
dataDir := os.Getenv("DATA_DIR")
|
||||||
|
if dataDir == "" {
|
||||||
|
dataDir = "./data"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
if err := db.Init(dataDir); err != nil {
|
||||||
|
slog.Error("database init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
if err := db.SeedSchedulerDefaults(db.Get()); err != nil {
|
||||||
|
slog.Warn("seed scheduler defaults failed", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create Matrix client
|
||||||
|
cfg := bot.Config{
|
||||||
|
Homeserver: os.Getenv("HOMESERVER_URL"),
|
||||||
|
UserID: os.Getenv("BOT_USER_ID"),
|
||||||
|
Password: os.Getenv("BOT_PASSWORD"),
|
||||||
|
DataDir: dataDir,
|
||||||
|
DisplayName: envOr("BOT_DISPLAY_NAME", "GogoBee"),
|
||||||
|
}
|
||||||
|
|
||||||
|
client, err := bot.NewClient(cfg)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("client init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create plugin registry
|
||||||
|
registry := bot.NewRegistry()
|
||||||
|
|
||||||
|
// ---- Register plugins in dependency order ----
|
||||||
|
|
||||||
|
// Foundation (passive tracking)
|
||||||
|
xpPlugin := plugin.NewXPPlugin(client)
|
||||||
|
registry.Register(xpPlugin)
|
||||||
|
|
||||||
|
ratePlugin := plugin.NewRateLimitsPlugin(client)
|
||||||
|
registry.Register(ratePlugin)
|
||||||
|
|
||||||
|
registry.Register(plugin.NewReputationPlugin(client, xpPlugin))
|
||||||
|
registry.Register(plugin.NewStatsPlugin(client))
|
||||||
|
registry.Register(plugin.NewStreaksPlugin(client))
|
||||||
|
|
||||||
|
// Interactive
|
||||||
|
registry.Register(plugin.NewTriviaPlugin(client))
|
||||||
|
registry.Register(plugin.NewRemindersPlugin(client))
|
||||||
|
registry.Register(plugin.NewPresencePlugin(client))
|
||||||
|
registry.Register(plugin.NewFunPlugin(client))
|
||||||
|
registry.Register(plugin.NewToolsPlugin(client))
|
||||||
|
registry.Register(plugin.NewUserPlugin(client))
|
||||||
|
|
||||||
|
// Entertainment / Lookup
|
||||||
|
registry.Register(plugin.NewRetroPlugin(client))
|
||||||
|
registry.Register(plugin.NewLookupPlugin(client, ratePlugin))
|
||||||
|
registry.Register(plugin.NewCountdownPlugin(client))
|
||||||
|
registry.Register(plugin.NewStocksPlugin(client))
|
||||||
|
registry.Register(plugin.NewConcertsPlugin(client))
|
||||||
|
registry.Register(plugin.NewAnimePlugin(client))
|
||||||
|
registry.Register(plugin.NewMoviesPlugin(client))
|
||||||
|
|
||||||
|
// Tracking (passive)
|
||||||
|
registry.Register(plugin.NewAchievementsPlugin(client, registry))
|
||||||
|
registry.Register(plugin.NewReactionsPlugin(client))
|
||||||
|
registry.Register(plugin.NewMarkovPlugin(client))
|
||||||
|
registry.Register(plugin.NewURLsPlugin(client))
|
||||||
|
|
||||||
|
// LLM-powered (passive)
|
||||||
|
registry.Register(plugin.NewLLMPassivePlugin(client, xpPlugin))
|
||||||
|
|
||||||
|
// Scheduled
|
||||||
|
wotdPlugin := plugin.NewWOTDPlugin(client)
|
||||||
|
registry.Register(wotdPlugin)
|
||||||
|
holidaysPlugin := plugin.NewHolidaysPlugin(client)
|
||||||
|
registry.Register(holidaysPlugin)
|
||||||
|
gamingPlugin := plugin.NewGamingPlugin(client)
|
||||||
|
registry.Register(gamingPlugin)
|
||||||
|
birthdayPlugin := plugin.NewBirthdayPlugin(client, xpPlugin)
|
||||||
|
registry.Register(birthdayPlugin)
|
||||||
|
|
||||||
|
// Utility / Meta
|
||||||
|
registry.Register(plugin.NewBotInfoPlugin(client))
|
||||||
|
registry.Register(plugin.NewHowAmIPlugin(client))
|
||||||
|
registry.Register(plugin.NewVibePlugin(client))
|
||||||
|
registry.Register(plugin.NewShadePlugin(client))
|
||||||
|
registry.Register(plugin.NewWelcomePlugin(client, xpPlugin, registry))
|
||||||
|
|
||||||
|
// Initialize all plugins
|
||||||
|
if err := registry.Init(); err != nil {
|
||||||
|
slog.Error("plugin init failed", "err", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Set up event handlers ----
|
||||||
|
|
||||||
|
syncer := client.Syncer.(*mautrix.DefaultSyncer)
|
||||||
|
|
||||||
|
// Auto-join on invite
|
||||||
|
syncer.OnEventType(event.StateMember, func(ctx context.Context, evt *event.Event) {
|
||||||
|
if evt.GetStateKey() == string(client.UserID) {
|
||||||
|
mem := evt.Content.AsMember()
|
||||||
|
if mem.Membership == event.MembershipInvite {
|
||||||
|
_, err := client.JoinRoomByID(ctx, evt.RoomID)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("failed to join room", "room", evt.RoomID, "err", err)
|
||||||
|
} else {
|
||||||
|
slog.Info("joined room", "room", evt.RoomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Message handler
|
||||||
|
syncer.OnEventType(event.EventMessage, func(ctx context.Context, evt *event.Event) {
|
||||||
|
// Skip own messages
|
||||||
|
if evt.Sender == client.UserID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content := evt.Content.AsMessage()
|
||||||
|
if content == nil || content.Body == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
body := content.Body
|
||||||
|
msgCtx := plugin.MessageContext{
|
||||||
|
RoomID: evt.RoomID,
|
||||||
|
EventID: evt.ID,
|
||||||
|
Sender: evt.Sender,
|
||||||
|
Body: body,
|
||||||
|
IsCommand: strings.HasPrefix(strings.TrimSpace(body), "!"),
|
||||||
|
Event: evt,
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.DispatchMessage(msgCtx)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reaction handler
|
||||||
|
syncer.OnEventType(event.EventReaction, func(ctx context.Context, evt *event.Event) {
|
||||||
|
if evt.Sender == client.UserID {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
content := evt.Content.AsReaction()
|
||||||
|
if content == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reactCtx := plugin.ReactionContext{
|
||||||
|
RoomID: evt.RoomID,
|
||||||
|
EventID: evt.ID,
|
||||||
|
Sender: evt.Sender,
|
||||||
|
TargetEvent: content.RelatesTo.EventID,
|
||||||
|
Emoji: content.RelatesTo.Key,
|
||||||
|
Event: evt,
|
||||||
|
}
|
||||||
|
|
||||||
|
registry.DispatchReaction(reactCtx)
|
||||||
|
})
|
||||||
|
|
||||||
|
// ---- Set up cron scheduler ----
|
||||||
|
scheduler := cron.New()
|
||||||
|
setupScheduledJobs(scheduler, client, wotdPlugin, holidaysPlugin, gamingPlugin, birthdayPlugin)
|
||||||
|
scheduler.Start()
|
||||||
|
|
||||||
|
// ---- Start syncing ----
|
||||||
|
slog.Info("GogoBee starting sync...")
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
// Graceful shutdown
|
||||||
|
sigCh := make(chan os.Signal, 1)
|
||||||
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
go func() {
|
||||||
|
sig := <-sigCh
|
||||||
|
slog.Info("shutting down", "signal", sig)
|
||||||
|
scheduler.Stop()
|
||||||
|
cancel()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if err := client.SyncWithContext(ctx); err != nil {
|
||||||
|
slog.Error("sync stopped", "err", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("GogoBee stopped")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupScheduledJobs(
|
||||||
|
c *cron.Cron,
|
||||||
|
client *mautrix.Client,
|
||||||
|
wotd *plugin.WOTDPlugin,
|
||||||
|
holidays *plugin.HolidaysPlugin,
|
||||||
|
gaming *plugin.GamingPlugin,
|
||||||
|
birthday *plugin.BirthdayPlugin,
|
||||||
|
) {
|
||||||
|
rooms := getRooms()
|
||||||
|
|
||||||
|
// WOTD prefetch at 00:05
|
||||||
|
c.AddFunc("5 0 * * *", func() {
|
||||||
|
slog.Info("scheduler: prefetching WOTD")
|
||||||
|
wotd.Prefetch()
|
||||||
|
})
|
||||||
|
|
||||||
|
// WOTD post at 08:00
|
||||||
|
c.AddFunc("0 8 * * *", func() {
|
||||||
|
slog.Info("scheduler: posting WOTD")
|
||||||
|
for _, r := range rooms {
|
||||||
|
wotd.PostWOTD(r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Holidays at 07:00
|
||||||
|
c.AddFunc("0 7 * * *", func() {
|
||||||
|
slog.Info("scheduler: posting holidays")
|
||||||
|
for _, r := range rooms {
|
||||||
|
holidays.PostHolidays(r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Game releases Monday 09:00
|
||||||
|
c.AddFunc("0 9 * * 1", func() {
|
||||||
|
slog.Info("scheduler: posting game releases")
|
||||||
|
for _, r := range rooms {
|
||||||
|
gaming.PostReleases(r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Birthday check at 06:00
|
||||||
|
c.AddFunc("0 6 * * *", func() {
|
||||||
|
slog.Info("scheduler: checking birthdays")
|
||||||
|
for _, r := range rooms {
|
||||||
|
birthday.CheckAndPost(r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Reminder polling every 30 seconds
|
||||||
|
c.AddFunc("@every 30s", func() {
|
||||||
|
plugin.FirePendingReminders(client)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getRooms() []id.RoomID {
|
||||||
|
roomStr := os.Getenv("BROADCAST_ROOMS")
|
||||||
|
if roomStr == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var rooms []id.RoomID
|
||||||
|
for _, r := range splitAndTrim(roomStr, ",") {
|
||||||
|
if r != "" {
|
||||||
|
rooms = append(rooms, id.RoomID(r))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rooms
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitAndTrim(s, sep string) []string {
|
||||||
|
parts := make([]string, 0)
|
||||||
|
for _, p := range splitStr(s, sep) {
|
||||||
|
t := trimSpace(p)
|
||||||
|
if t != "" {
|
||||||
|
parts = append(parts, t)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func splitStr(s, sep string) []string {
|
||||||
|
if s == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]string, 0)
|
||||||
|
for {
|
||||||
|
i := indexOf(s, sep)
|
||||||
|
if i < 0 {
|
||||||
|
result = append(result, s)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
result = append(result, s[:i])
|
||||||
|
s = s[i+len(sep):]
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func indexOf(s, sub string) int {
|
||||||
|
for i := 0; i+len(sub) <= len(s); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
func trimSpace(s string) string {
|
||||||
|
start := 0
|
||||||
|
for start < len(s) && (s[start] == ' ' || s[start] == '\t' || s[start] == '\n' || s[start] == '\r') {
|
||||||
|
start++
|
||||||
|
}
|
||||||
|
end := len(s)
|
||||||
|
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\n' || s[end-1] == '\r') {
|
||||||
|
end--
|
||||||
|
}
|
||||||
|
return s[start:end]
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustGetwd() string {
|
||||||
|
wd, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
return "(unknown)"
|
||||||
|
}
|
||||||
|
return wd
|
||||||
|
}
|
||||||
|
|
||||||
|
func envOr(key, fallback string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
2341
package-lock.json
generated
2341
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
35
package.json
35
package.json
@@ -1,35 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "freebee",
|
|
||||||
"version": "1.0.0",
|
|
||||||
"description": "A full-featured Matrix community bot with E2EE support, plugin architecture, and community engagement features",
|
|
||||||
"license": "MIT",
|
|
||||||
"main": "dist/index.js",
|
|
||||||
"scripts": {
|
|
||||||
"build": "tsc",
|
|
||||||
"start": "node dist/index.js",
|
|
||||||
"dev": "tsx src/index.ts"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"better-sqlite3": "^9.4.3",
|
|
||||||
"chrono-node": "^2.7.0",
|
|
||||||
"dotenv": "^16.4.5",
|
|
||||||
"fake-indexeddb": "^6.2.5",
|
|
||||||
"howlongtobeat": "^1.8.0",
|
|
||||||
"luxon": "^3.4.4",
|
|
||||||
"mathjs": "^15.1.1",
|
|
||||||
"matrix-js-sdk": "^41.1.0-rc.0",
|
|
||||||
"node-html-parser": "^6.1.13",
|
|
||||||
"qrcode": "^1.5.4",
|
|
||||||
"uuid": "^9.0.1",
|
|
||||||
"winston": "^3.12.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@types/better-sqlite3": "^7.6.8",
|
|
||||||
"@types/luxon": "^3.4.2",
|
|
||||||
"@types/node": "^20.11.30",
|
|
||||||
"@types/qrcode": "^1.5.6",
|
|
||||||
"@types/uuid": "^9.0.8",
|
|
||||||
"tsx": "^4.7.0",
|
|
||||||
"typescript": "^5.4.3"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
570
src/db/index.ts
570
src/db/index.ts
@@ -1,570 +0,0 @@
|
|||||||
import Database from "better-sqlite3";
|
|
||||||
import path from "path";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
let db: Database.Database;
|
|
||||||
|
|
||||||
export function getDb(): Database.Database {
|
|
||||||
if (!db) throw new Error("Database not initialized. Call initDb() first.");
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function initDb(dataDir: string): Database.Database {
|
|
||||||
const dbPath = path.join(dataDir, "freebee.db");
|
|
||||||
db = new Database(dbPath);
|
|
||||||
|
|
||||||
db.pragma("journal_mode = WAL");
|
|
||||||
db.pragma("foreign_keys = ON");
|
|
||||||
|
|
||||||
createTables();
|
|
||||||
runMigrations();
|
|
||||||
seedSchedulerDefaults();
|
|
||||||
|
|
||||||
logger.info(`Database initialized at ${dbPath}`);
|
|
||||||
return db;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createTables(): void {
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
xp INTEGER NOT NULL DEFAULT 0,
|
|
||||||
level INTEGER NOT NULL DEFAULT 1,
|
|
||||||
rep INTEGER NOT NULL DEFAULT 0,
|
|
||||||
timezone TEXT,
|
|
||||||
PRIMARY KEY (user_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS user_stats (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
total_messages INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_words INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_characters INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_links INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_images INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_questions INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_exclamations INTEGER NOT NULL DEFAULT 0,
|
|
||||||
total_emojis INTEGER NOT NULL DEFAULT 0,
|
|
||||||
longest_message INTEGER NOT NULL DEFAULT 0,
|
|
||||||
shortest_message INTEGER,
|
|
||||||
avg_words_per_message REAL NOT NULL DEFAULT 0,
|
|
||||||
hourly_distribution TEXT NOT NULL DEFAULT '{}',
|
|
||||||
daily_distribution TEXT NOT NULL DEFAULT '{}',
|
|
||||||
current_streak INTEGER NOT NULL DEFAULT 0,
|
|
||||||
longest_streak INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_active_date TEXT,
|
|
||||||
PRIMARY KEY (user_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS xp_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
amount INTEGER NOT NULL,
|
|
||||||
reason TEXT NOT NULL,
|
|
||||||
granted_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS rep_cooldowns (
|
|
||||||
giver_id TEXT NOT NULL,
|
|
||||||
receiver_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
PRIMARY KEY (giver_id, receiver_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS reminders (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
remind_at TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
fired INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS daily_activity (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
message_count INTEGER NOT NULL DEFAULT 1,
|
|
||||||
PRIMARY KEY (user_id, room_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS daily_first (
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
timestamp TEXT NOT NULL,
|
|
||||||
PRIMARY KEY (room_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS wotd_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
word TEXT NOT NULL,
|
|
||||||
definition TEXT,
|
|
||||||
example TEXT,
|
|
||||||
part_of_speech TEXT,
|
|
||||||
posted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(room_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS wotd_usage (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
xp_awarded INTEGER NOT NULL DEFAULT 0,
|
|
||||||
detected_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(user_id, room_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS holidays_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
holidays_json TEXT NOT NULL,
|
|
||||||
posted_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(room_id, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS releases_cache (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
game_name TEXT NOT NULL,
|
|
||||||
game_slug TEXT,
|
|
||||||
release_date TEXT,
|
|
||||||
platforms TEXT,
|
|
||||||
genres TEXT,
|
|
||||||
rating REAL,
|
|
||||||
data_json TEXT NOT NULL,
|
|
||||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS release_watchlist (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
game_name TEXT NOT NULL,
|
|
||||||
game_slug TEXT,
|
|
||||||
release_date TEXT,
|
|
||||||
notified_day_before INTEGER NOT NULL DEFAULT 0,
|
|
||||||
notified_day_of INTEGER NOT NULL DEFAULT 0,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(user_id, room_id, game_name)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS hltb_cache (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
game_name TEXT NOT NULL,
|
|
||||||
search_term TEXT NOT NULL,
|
|
||||||
main_story REAL,
|
|
||||||
main_extra REAL,
|
|
||||||
completionist REAL,
|
|
||||||
data_json TEXT NOT NULL,
|
|
||||||
fetched_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS achievements (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
achievement_key TEXT NOT NULL,
|
|
||||||
unlocked_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(user_id, room_id, achievement_key)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS quotes (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
quoted_by TEXT NOT NULL,
|
|
||||||
event_id TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS now_playing (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
game TEXT NOT NULL,
|
|
||||||
started_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
PRIMARY KEY (user_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS backlog (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
game TEXT NOT NULL,
|
|
||||||
completed INTEGER NOT NULL DEFAULT 0,
|
|
||||||
added_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
completed_at TEXT,
|
|
||||||
UNIQUE(user_id, room_id, game)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS predictions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
prediction TEXT NOT NULL,
|
|
||||||
outcome TEXT,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
resolved_at TEXT
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS keyword_watches (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
keyword TEXT NOT NULL,
|
|
||||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
|
||||||
UNIQUE(user_id, room_id, keyword)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS scheduler_config (
|
|
||||||
job_name TEXT PRIMARY KEY,
|
|
||||||
hour INTEGER NOT NULL,
|
|
||||||
minute INTEGER NOT NULL,
|
|
||||||
enabled INTEGER NOT NULL DEFAULT 1
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS shade_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
target_id TEXT,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
confidence REAL,
|
|
||||||
classified_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS shade_optout (
|
|
||||||
user_id TEXT PRIMARY KEY,
|
|
||||||
opted_out_at TEXT NOT NULL DEFAULT (datetime('now'))
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Birthdays
|
|
||||||
CREATE TABLE IF NOT EXISTS birthdays (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
month INTEGER NOT NULL,
|
|
||||||
day INTEGER NOT NULL,
|
|
||||||
year INTEGER,
|
|
||||||
PRIMARY KEY (user_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS birthday_fired (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
year INTEGER NOT NULL,
|
|
||||||
PRIMARY KEY (user_id, year)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Trivia
|
|
||||||
CREATE TABLE IF NOT EXISTS trivia_sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
question TEXT NOT NULL,
|
|
||||||
answer TEXT NOT NULL,
|
|
||||||
category TEXT,
|
|
||||||
difficulty TEXT,
|
|
||||||
asked_at INTEGER DEFAULT (unixepoch()),
|
|
||||||
answered_by TEXT,
|
|
||||||
answered_at INTEGER,
|
|
||||||
correct INTEGER DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS trivia_scores (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
total_correct INTEGER DEFAULT 0,
|
|
||||||
total_points INTEGER DEFAULT 0,
|
|
||||||
total_answered INTEGER DEFAULT 0,
|
|
||||||
current_streak INTEGER DEFAULT 0,
|
|
||||||
best_streak INTEGER DEFAULT 0,
|
|
||||||
fastest_ms INTEGER,
|
|
||||||
PRIMARY KEY (user_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- LLM passive classification
|
|
||||||
CREATE TABLE IF NOT EXISTS llm_classifications (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
event_id TEXT NOT NULL,
|
|
||||||
profanity INTEGER DEFAULT 0,
|
|
||||||
profanity_severity INTEGER DEFAULT 0,
|
|
||||||
insult INTEGER DEFAULT 0,
|
|
||||||
insult_type TEXT,
|
|
||||||
insult_target TEXT,
|
|
||||||
insult_severity INTEGER DEFAULT 0,
|
|
||||||
sentiment TEXT DEFAULT 'neutral',
|
|
||||||
gratitude INTEGER DEFAULT 0,
|
|
||||||
gratitude_toward TEXT,
|
|
||||||
wotd_used INTEGER DEFAULT 0,
|
|
||||||
wotd_correct INTEGER DEFAULT 0,
|
|
||||||
classified_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS potty_mouth (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
total INTEGER DEFAULT 0,
|
|
||||||
severity_1 INTEGER DEFAULT 0,
|
|
||||||
severity_2 INTEGER DEFAULT 0,
|
|
||||||
severity_3 INTEGER DEFAULT 0,
|
|
||||||
PRIMARY KEY (user_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS insult_log (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
total_thrown INTEGER DEFAULT 0,
|
|
||||||
direct_thrown INTEGER DEFAULT 0,
|
|
||||||
indirect_thrown INTEGER DEFAULT 0,
|
|
||||||
times_targeted INTEGER DEFAULT 0,
|
|
||||||
PRIMARY KEY (user_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Stocks
|
|
||||||
CREATE TABLE IF NOT EXISTS stocks_cache (
|
|
||||||
ticker TEXT PRIMARY KEY,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
cached_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS stock_watchlist (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
ticker TEXT NOT NULL,
|
|
||||||
created_at INTEGER DEFAULT (unixepoch()),
|
|
||||||
UNIQUE(user_id, room_id, ticker)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Rate limiting
|
|
||||||
CREATE TABLE IF NOT EXISTS command_usage (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
command TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
count INTEGER DEFAULT 0,
|
|
||||||
PRIMARY KEY (user_id, command, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Concerts
|
|
||||||
CREATE TABLE IF NOT EXISTS concerts_cache (
|
|
||||||
location_key TEXT PRIMARY KEY,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
cached_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS concert_watchlist (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
artist TEXT NOT NULL,
|
|
||||||
created_at INTEGER DEFAULT (unixepoch()),
|
|
||||||
UNIQUE(user_id, room_id, artist)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Anime
|
|
||||||
CREATE TABLE IF NOT EXISTS anime_watchlist (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
mal_id INTEGER NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
airing_date TEXT,
|
|
||||||
notified INTEGER DEFAULT 0,
|
|
||||||
created_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS anime_cache (
|
|
||||||
mal_id INTEGER PRIMARY KEY,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
cached_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Movies/TV
|
|
||||||
CREATE TABLE IF NOT EXISTS movie_watchlist (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
tmdb_id INTEGER NOT NULL,
|
|
||||||
title TEXT NOT NULL,
|
|
||||||
media_type TEXT NOT NULL,
|
|
||||||
release_date TEXT,
|
|
||||||
notified INTEGER DEFAULT 0,
|
|
||||||
created_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS movie_cache (
|
|
||||||
tmdb_id INTEGER NOT NULL,
|
|
||||||
media_type TEXT NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
cached_at INTEGER DEFAULT (unixepoch()),
|
|
||||||
PRIMARY KEY (tmdb_id, media_type)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Countdowns
|
|
||||||
CREATE TABLE IF NOT EXISTS countdowns (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
label TEXT NOT NULL,
|
|
||||||
target_date TEXT NOT NULL,
|
|
||||||
public INTEGER DEFAULT 1,
|
|
||||||
completed INTEGER DEFAULT 0,
|
|
||||||
created_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Presence
|
|
||||||
CREATE TABLE IF NOT EXISTS presence (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
status TEXT NOT NULL DEFAULT 'online',
|
|
||||||
status_message TEXT,
|
|
||||||
updated_at INTEGER DEFAULT (unixepoch()),
|
|
||||||
PRIMARY KEY (user_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Markov corpus
|
|
||||||
CREATE TABLE IF NOT EXISTS markov_corpus (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
message TEXT NOT NULL,
|
|
||||||
added_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_markov_user_room ON markov_corpus (user_id, room_id);
|
|
||||||
|
|
||||||
-- Retro game cache (GiantBomb)
|
|
||||||
CREATE TABLE IF NOT EXISTS retro_cache (
|
|
||||||
search_term TEXT PRIMARY KEY,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
cached_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Urban Dictionary cache
|
|
||||||
CREATE TABLE IF NOT EXISTS urban_cache (
|
|
||||||
term TEXT PRIMARY KEY,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
cached_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Room message milestones
|
|
||||||
CREATE TABLE IF NOT EXISTS room_milestones (
|
|
||||||
room_id TEXT PRIMARY KEY,
|
|
||||||
total_messages INTEGER NOT NULL DEFAULT 0,
|
|
||||||
last_milestone INTEGER NOT NULL DEFAULT 0
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Reaction tracking
|
|
||||||
CREATE TABLE IF NOT EXISTS reaction_log (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
giver_id TEXT NOT NULL,
|
|
||||||
receiver_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
emoji TEXT NOT NULL,
|
|
||||||
event_id TEXT NOT NULL,
|
|
||||||
created_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_reaction_room ON reaction_log (room_id);
|
|
||||||
|
|
||||||
-- Sentiment aggregates (rolled up from llm_classifications)
|
|
||||||
CREATE TABLE IF NOT EXISTS sentiment_stats (
|
|
||||||
user_id TEXT NOT NULL,
|
|
||||||
room_id TEXT NOT NULL,
|
|
||||||
neutral INTEGER DEFAULT 0,
|
|
||||||
happy INTEGER DEFAULT 0,
|
|
||||||
sad INTEGER DEFAULT 0,
|
|
||||||
angry INTEGER DEFAULT 0,
|
|
||||||
excited INTEGER DEFAULT 0,
|
|
||||||
funny INTEGER DEFAULT 0,
|
|
||||||
love INTEGER DEFAULT 0,
|
|
||||||
scared INTEGER DEFAULT 0,
|
|
||||||
total_classified INTEGER DEFAULT 0,
|
|
||||||
PRIMARY KEY (user_id, room_id)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- Daily prefetch cache (API data fetched early, posted later)
|
|
||||||
CREATE TABLE IF NOT EXISTS daily_prefetch (
|
|
||||||
job_name TEXT NOT NULL,
|
|
||||||
date TEXT NOT NULL,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
fetched_at INTEGER DEFAULT (unixepoch()),
|
|
||||||
PRIMARY KEY (job_name, date)
|
|
||||||
);
|
|
||||||
|
|
||||||
-- URL preview cache
|
|
||||||
CREATE TABLE IF NOT EXISTS url_cache (
|
|
||||||
url TEXT PRIMARY KEY,
|
|
||||||
title TEXT,
|
|
||||||
description TEXT,
|
|
||||||
cached_at INTEGER DEFAULT (unixepoch())
|
|
||||||
);
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function runMigrations(): void {
|
|
||||||
const cols = db
|
|
||||||
.prepare(`PRAGMA table_info(llm_classifications)`)
|
|
||||||
.all() as { name: string }[];
|
|
||||||
if (cols.length > 0) {
|
|
||||||
if (!cols.find((c) => c.name === "sentiment")) {
|
|
||||||
db.exec(`ALTER TABLE llm_classifications ADD COLUMN sentiment TEXT DEFAULT 'neutral'`);
|
|
||||||
logger.info("Migration: added sentiment column to llm_classifications");
|
|
||||||
}
|
|
||||||
if (!cols.find((c) => c.name === "gratitude")) {
|
|
||||||
db.exec(`ALTER TABLE llm_classifications ADD COLUMN gratitude INTEGER DEFAULT 0`);
|
|
||||||
db.exec(`ALTER TABLE llm_classifications ADD COLUMN gratitude_toward TEXT`);
|
|
||||||
logger.info("Migration: added gratitude columns to llm_classifications");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Backfill sentiment_stats from existing llm_classifications
|
|
||||||
const sentimentCount = db.prepare(`SELECT COUNT(*) as c FROM sentiment_stats`).get() as { c: number };
|
|
||||||
const classCount = db.prepare(`SELECT COUNT(*) as c FROM llm_classifications`).get() as { c: number };
|
|
||||||
if (sentimentCount.c === 0 && classCount.c > 0) {
|
|
||||||
db.exec(`
|
|
||||||
INSERT INTO sentiment_stats (user_id, room_id, neutral, happy, sad, angry, excited, funny, love, scared, total_classified)
|
|
||||||
SELECT user_id, room_id,
|
|
||||||
SUM(CASE WHEN sentiment = 'neutral' THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN sentiment = 'happy' THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN sentiment = 'sad' THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN sentiment = 'angry' THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN sentiment = 'excited' THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN sentiment = 'funny' THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN sentiment = 'love' THEN 1 ELSE 0 END),
|
|
||||||
SUM(CASE WHEN sentiment = 'scared' THEN 1 ELSE 0 END),
|
|
||||||
COUNT(*)
|
|
||||||
FROM llm_classifications GROUP BY user_id, room_id
|
|
||||||
`);
|
|
||||||
logger.info("Migration: backfilled sentiment_stats from llm_classifications");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function seedSchedulerDefaults(): void {
|
|
||||||
const insert = db.prepare(`
|
|
||||||
INSERT OR IGNORE INTO scheduler_config (job_name, hour, minute, enabled)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
|
|
||||||
const holidaysHour = parseInt(process.env.SCHEDULE_HOLIDAYS_HOUR ?? "7", 10);
|
|
||||||
const holidaysMinute = parseInt(process.env.SCHEDULE_HOLIDAYS_MINUTE ?? "0", 10);
|
|
||||||
const releasesHour = parseInt(process.env.SCHEDULE_RELEASES_HOUR ?? "19", 10);
|
|
||||||
const releasesMinute = parseInt(process.env.SCHEDULE_RELEASES_MINUTE ?? "0", 10);
|
|
||||||
|
|
||||||
insert.run("prefetch", 0, 5, 1);
|
|
||||||
insert.run("maintenance", 0, 15, 1);
|
|
||||||
insert.run("holidays", holidaysHour, holidaysMinute, 1);
|
|
||||||
insert.run("releases", releasesHour, releasesMinute, 1);
|
|
||||||
insert.run("wotd", 8, 0, 1);
|
|
||||||
insert.run("birthday_check", 7, 5, 1);
|
|
||||||
insert.run("anime_releases", 19, 30, 1);
|
|
||||||
insert.run("movie_releases", 20, 0, 1);
|
|
||||||
insert.run("concert_digest", 10, 0, 1);
|
|
||||||
}
|
|
||||||
368
src/index.ts
368
src/index.ts
@@ -1,368 +0,0 @@
|
|||||||
import "fake-indexeddb/auto";
|
|
||||||
import "dotenv/config";
|
|
||||||
import path from "path";
|
|
||||||
import fs from "fs";
|
|
||||||
import { BotClient } from "./matrix-client";
|
|
||||||
import { initDb } from "./db";
|
|
||||||
import logger from "./utils/logger";
|
|
||||||
import { loginWithPassword, isTokenValid } from "./utils/auth";
|
|
||||||
import { PluginRegistry } from "./plugins/base";
|
|
||||||
import { XpPlugin } from "./plugins/xp";
|
|
||||||
import { ReputationPlugin } from "./plugins/reputation";
|
|
||||||
import { StatsPlugin } from "./plugins/stats";
|
|
||||||
import { StreaksPlugin } from "./plugins/streaks";
|
|
||||||
import { RemindersPlugin } from "./plugins/reminders";
|
|
||||||
import { UserPlugin } from "./plugins/user";
|
|
||||||
import { FunPlugin } from "./plugins/fun";
|
|
||||||
import { WotdPlugin } from "./plugins/wotd";
|
|
||||||
import { HolidaysPlugin } from "./plugins/holidays";
|
|
||||||
import { GamingPlugin } from "./plugins/gaming";
|
|
||||||
import { DailyScheduler } from "./plugins/daily";
|
|
||||||
import { AchievementsPlugin } from "./plugins/achievements";
|
|
||||||
import { ShadePlugin } from "./plugins/shade";
|
|
||||||
import { RateLimitsPlugin } from "./plugins/ratelimits";
|
|
||||||
import { BirthdayPlugin } from "./plugins/birthday";
|
|
||||||
import { TriviaPlugin } from "./plugins/trivia";
|
|
||||||
import { LlmPassivePlugin } from "./plugins/llm-passive";
|
|
||||||
import { StocksPlugin } from "./plugins/stocks";
|
|
||||||
import { ConcertsPlugin } from "./plugins/concerts";
|
|
||||||
import { AnimePlugin } from "./plugins/anime";
|
|
||||||
import { MoviesPlugin } from "./plugins/movies";
|
|
||||||
import { LookupPlugin } from "./plugins/lookup";
|
|
||||||
import { PresencePlugin } from "./plugins/presence";
|
|
||||||
import { CountdownPlugin } from "./plugins/countdown";
|
|
||||||
import { WelcomePlugin } from "./plugins/welcome";
|
|
||||||
import { MarkovPlugin } from "./plugins/markov";
|
|
||||||
import { UrlsPlugin } from "./plugins/urls";
|
|
||||||
import { ToolsPlugin } from "./plugins/tools";
|
|
||||||
import { ReactionsPlugin } from "./plugins/reactions";
|
|
||||||
import { BotInfoPlugin } from "./plugins/botinfo";
|
|
||||||
import { RetroPlugin } from "./plugins/retro";
|
|
||||||
import { HowAmIPlugin } from "./plugins/howami";
|
|
||||||
import { VibePlugin } from "./plugins/vibe";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Ensure we have a valid access token. If the current token is invalid and a
|
|
||||||
* password is configured, log in automatically and persist the new token.
|
|
||||||
*/
|
|
||||||
async function resolveAccessToken(
|
|
||||||
homeserverUrl: string,
|
|
||||||
botUserId: string,
|
|
||||||
currentToken: string | undefined,
|
|
||||||
password: string | undefined,
|
|
||||||
dataDir: string
|
|
||||||
): Promise<string> {
|
|
||||||
const deviceFile = path.join(dataDir, "device.json");
|
|
||||||
const hasDeviceFile = fs.existsSync(deviceFile);
|
|
||||||
|
|
||||||
// Try to load the stored token from device.json first
|
|
||||||
let storedToken: string | undefined;
|
|
||||||
if (hasDeviceFile) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
|
||||||
storedToken = data.accessToken;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prefer stored token (device.json) over env var
|
|
||||||
const effectiveToken = storedToken ?? currentToken;
|
|
||||||
|
|
||||||
// If we have a stored device identity AND a valid token, reuse them
|
|
||||||
if (hasDeviceFile && effectiveToken) {
|
|
||||||
const valid = await isTokenValid(homeserverUrl, effectiveToken);
|
|
||||||
if (valid) {
|
|
||||||
logger.info("Access token is valid, device identity exists");
|
|
||||||
return effectiveToken;
|
|
||||||
}
|
|
||||||
logger.warn("Access token is invalid or expired");
|
|
||||||
}
|
|
||||||
|
|
||||||
// No device.json means fresh crypto store — we MUST do a password login
|
|
||||||
// to get a new token + device pair. Reusing an old token's device would
|
|
||||||
// cause OTK conflicts since the server has keys we don't have locally.
|
|
||||||
if (!hasDeviceFile && effectiveToken) {
|
|
||||||
logger.info("No device.json found — fresh crypto store requires a new device via password login");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!password) {
|
|
||||||
if (!hasDeviceFile) {
|
|
||||||
logger.error(
|
|
||||||
"No device.json found and no MATRIX_BOT_PASSWORD set. " +
|
|
||||||
"A fresh crypto store requires a password login to create a new device. " +
|
|
||||||
"Set MATRIX_BOT_PASSWORD to proceed."
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
logger.error(
|
|
||||||
"Access token is invalid and no MATRIX_BOT_PASSWORD is set. " +
|
|
||||||
"Provide a valid MATRIX_ACCESS_TOKEN or set MATRIX_BOT_PASSWORD for automatic renewal."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info("Performing password login to obtain a new access token and device...");
|
|
||||||
|
|
||||||
// Reuse existing device ID only if we have the crypto store to match
|
|
||||||
let existingDeviceId: string | undefined;
|
|
||||||
if (hasDeviceFile) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
|
||||||
existingDeviceId = data.deviceId;
|
|
||||||
} catch { /* ignore parse errors */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const loginResult = await loginWithPassword(homeserverUrl, botUserId, password, existingDeviceId);
|
|
||||||
|
|
||||||
// Save the new device ID and access token together
|
|
||||||
fs.writeFileSync(
|
|
||||||
deviceFile,
|
|
||||||
JSON.stringify({ deviceId: loginResult.device_id, accessToken: loginResult.access_token }),
|
|
||||||
"utf-8"
|
|
||||||
);
|
|
||||||
fs.chmodSync(deviceFile, 0o600);
|
|
||||||
logger.info(`Device identity saved: ${loginResult.device_id}`);
|
|
||||||
|
|
||||||
return loginResult.access_token;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main(): Promise<void> {
|
|
||||||
const dataDir = process.env.DATA_DIR ?? "./data";
|
|
||||||
const homeserverUrl = process.env.MATRIX_HOMESERVER_URL;
|
|
||||||
const botUserId = process.env.MATRIX_BOT_USER_ID;
|
|
||||||
const botPassword = process.env.MATRIX_BOT_PASSWORD;
|
|
||||||
|
|
||||||
if (!homeserverUrl || !botUserId) {
|
|
||||||
logger.error("Missing required env vars: MATRIX_HOMESERVER_URL, MATRIX_BOT_USER_ID");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!process.env.MATRIX_ACCESS_TOKEN && !botPassword) {
|
|
||||||
logger.error("Provide either MATRIX_ACCESS_TOKEN or MATRIX_BOT_PASSWORD (or both)");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ensure data directory exists
|
|
||||||
fs.mkdirSync(dataDir, { recursive: true });
|
|
||||||
|
|
||||||
// Crypto reset: wipe local crypto store and device identity
|
|
||||||
const resetMarker = path.join(dataDir, ".crypto_reset_needed");
|
|
||||||
const needsCryptoReset = process.env.CRYPTO_RESET === "true" || fs.existsSync(resetMarker);
|
|
||||||
if (needsCryptoReset) {
|
|
||||||
const cryptoDir = path.join(dataDir, "crypto-js");
|
|
||||||
const deviceFile = path.join(dataDir, "device.json");
|
|
||||||
|
|
||||||
// Try to delete the old device from the server
|
|
||||||
let oldDeviceId: string | undefined;
|
|
||||||
if (fs.existsSync(deviceFile)) {
|
|
||||||
try {
|
|
||||||
const data = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
|
||||||
oldDeviceId = data.deviceId;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (oldDeviceId && process.env.MATRIX_ACCESS_TOKEN) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${homeserverUrl}/_matrix/client/v3/devices/${encodeURIComponent(oldDeviceId)}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
headers: { Authorization: `Bearer ${process.env.MATRIX_ACCESS_TOKEN}`, "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({ auth: { type: "m.login.password" } }),
|
|
||||||
});
|
|
||||||
if (res.ok || res.status === 401) {
|
|
||||||
logger.info(`Deleted old device ${oldDeviceId} from server`);
|
|
||||||
} else {
|
|
||||||
logger.warn(`Could not delete old device ${oldDeviceId}: ${res.status}`);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Could not delete old device ${oldDeviceId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (fs.existsSync(cryptoDir)) fs.rmSync(cryptoDir, { recursive: true });
|
|
||||||
if (fs.existsSync(deviceFile)) fs.unlinkSync(deviceFile);
|
|
||||||
if (fs.existsSync(resetMarker)) fs.unlinkSync(resetMarker);
|
|
||||||
logger.info("Crypto reset: wiped crypto store and device.json. Starting with fresh device identity.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize database
|
|
||||||
initDb(dataDir);
|
|
||||||
|
|
||||||
// Validate / refresh the access token
|
|
||||||
const accessToken = await resolveAccessToken(
|
|
||||||
homeserverUrl,
|
|
||||||
botUserId,
|
|
||||||
process.env.MATRIX_ACCESS_TOKEN,
|
|
||||||
botPassword,
|
|
||||||
dataDir
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create the BotClient (wraps matrix-js-sdk with automatic E2EE)
|
|
||||||
const botClient = BotClient.create({
|
|
||||||
homeserverUrl,
|
|
||||||
accessToken,
|
|
||||||
userId: botUserId,
|
|
||||||
dataDir,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Parse bot rooms
|
|
||||||
const botRooms = (process.env.BOT_ROOMS ?? "")
|
|
||||||
.split(",")
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
// Instantiate plugins in dependency order
|
|
||||||
const xpPlugin = new XpPlugin(botClient);
|
|
||||||
const repPlugin = new ReputationPlugin(botClient, xpPlugin);
|
|
||||||
const statsPlugin = new StatsPlugin(botClient);
|
|
||||||
const streaksPlugin = new StreaksPlugin(botClient);
|
|
||||||
const remindersPlugin = new RemindersPlugin(botClient);
|
|
||||||
const userPlugin = new UserPlugin(botClient);
|
|
||||||
const funPlugin = new FunPlugin(botClient);
|
|
||||||
const wotdPlugin = new WotdPlugin(botClient, xpPlugin);
|
|
||||||
const holidaysPlugin = new HolidaysPlugin(botClient);
|
|
||||||
const gamingPlugin = new GamingPlugin(botClient);
|
|
||||||
const achievementsPlugin = new AchievementsPlugin(botClient);
|
|
||||||
const rateLimitPlugin = new RateLimitsPlugin(botClient);
|
|
||||||
const birthdayPlugin = new BirthdayPlugin(botClient, xpPlugin);
|
|
||||||
const triviaPlugin = new TriviaPlugin(botClient, xpPlugin);
|
|
||||||
const llmPassivePlugin = new LlmPassivePlugin(botClient, xpPlugin);
|
|
||||||
const stocksPlugin = new StocksPlugin(botClient);
|
|
||||||
const concertsPlugin = new ConcertsPlugin(botClient, rateLimitPlugin);
|
|
||||||
const animePlugin = new AnimePlugin(botClient);
|
|
||||||
const moviesPlugin = new MoviesPlugin(botClient);
|
|
||||||
const lookupPlugin = new LookupPlugin(botClient, rateLimitPlugin);
|
|
||||||
const presencePlugin = new PresencePlugin(botClient);
|
|
||||||
const countdownPlugin = new CountdownPlugin(botClient);
|
|
||||||
const markovPlugin = new MarkovPlugin(botClient);
|
|
||||||
const urlsPlugin = new UrlsPlugin(botClient);
|
|
||||||
const toolsPlugin = new ToolsPlugin(botClient);
|
|
||||||
const reactionsPlugin = new ReactionsPlugin(botClient);
|
|
||||||
const botInfoPlugin = new BotInfoPlugin(botClient);
|
|
||||||
const retroPlugin = new RetroPlugin(botClient);
|
|
||||||
const howAmIPlugin = new HowAmIPlugin(botClient);
|
|
||||||
const vibePlugin = new VibePlugin(botClient);
|
|
||||||
|
|
||||||
const dailyScheduler = new DailyScheduler(
|
|
||||||
botClient,
|
|
||||||
remindersPlugin,
|
|
||||||
wotdPlugin,
|
|
||||||
holidaysPlugin,
|
|
||||||
gamingPlugin,
|
|
||||||
birthdayPlugin,
|
|
||||||
animePlugin,
|
|
||||||
moviesPlugin,
|
|
||||||
concertsPlugin,
|
|
||||||
botRooms
|
|
||||||
);
|
|
||||||
|
|
||||||
// Register plugins with the registry
|
|
||||||
const registry = new PluginRegistry(botUserId);
|
|
||||||
|
|
||||||
// XP first (other plugins depend on it being processed first for passive XP)
|
|
||||||
registry.register(xpPlugin);
|
|
||||||
registry.register(repPlugin);
|
|
||||||
registry.register(statsPlugin);
|
|
||||||
registry.register(streaksPlugin);
|
|
||||||
registry.register(presencePlugin);
|
|
||||||
registry.register(remindersPlugin);
|
|
||||||
registry.register(userPlugin);
|
|
||||||
registry.register(funPlugin);
|
|
||||||
registry.register(wotdPlugin);
|
|
||||||
registry.register(holidaysPlugin);
|
|
||||||
registry.register(gamingPlugin);
|
|
||||||
registry.register(rateLimitPlugin);
|
|
||||||
registry.register(birthdayPlugin);
|
|
||||||
registry.register(triviaPlugin);
|
|
||||||
registry.register(llmPassivePlugin);
|
|
||||||
registry.register(stocksPlugin);
|
|
||||||
registry.register(concertsPlugin);
|
|
||||||
registry.register(animePlugin);
|
|
||||||
registry.register(moviesPlugin);
|
|
||||||
registry.register(lookupPlugin);
|
|
||||||
registry.register(countdownPlugin);
|
|
||||||
registry.register(markovPlugin);
|
|
||||||
registry.register(urlsPlugin);
|
|
||||||
registry.register(toolsPlugin);
|
|
||||||
registry.register(reactionsPlugin);
|
|
||||||
registry.register(botInfoPlugin);
|
|
||||||
registry.register(retroPlugin);
|
|
||||||
registry.register(howAmIPlugin);
|
|
||||||
registry.register(vibePlugin);
|
|
||||||
registry.register(achievementsPlugin);
|
|
||||||
registry.register(dailyScheduler);
|
|
||||||
|
|
||||||
// Welcome plugin needs registry reference for !help
|
|
||||||
const welcomePlugin = new WelcomePlugin(botClient, xpPlugin, registry);
|
|
||||||
registry.register(welcomePlugin);
|
|
||||||
|
|
||||||
// Register shade plugin only if feature flag is enabled
|
|
||||||
if (process.env.FEATURE_SHADE === "true") {
|
|
||||||
const shadePlugin = new ShadePlugin(botClient);
|
|
||||||
registry.register(shadePlugin);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire Matrix events to plugin registry via BotClient
|
|
||||||
botClient.onMessage(async (roomId: string, event: any) => {
|
|
||||||
await registry.dispatch(roomId, event);
|
|
||||||
});
|
|
||||||
|
|
||||||
botClient.onReaction(async (roomId: string, event: any) => {
|
|
||||||
await registry.dispatchReaction(roomId, event);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start the scheduler
|
|
||||||
dailyScheduler.start();
|
|
||||||
|
|
||||||
// Start the client (initializes crypto + starts syncing)
|
|
||||||
await botClient.start();
|
|
||||||
|
|
||||||
logger.info(`Freebee started as ${botUserId}`);
|
|
||||||
logger.info(`Listening in ${botRooms.length} configured room(s)`);
|
|
||||||
logger.info(`Registered ${registry["plugins"].length} plugin(s)`);
|
|
||||||
logger.info(`E2EE is handled automatically by matrix-js-sdk (Rust crypto)`);
|
|
||||||
|
|
||||||
// Kick-start key exchange in bot rooms (non-blocking).
|
|
||||||
// Sending an encrypted message advertises our device to room members.
|
|
||||||
if (botRooms.length > 0) {
|
|
||||||
(async () => {
|
|
||||||
for (const roomId of botRooms) {
|
|
||||||
try {
|
|
||||||
if (!botClient.isRoomEncrypted(roomId)) continue;
|
|
||||||
if (needsCryptoReset) {
|
|
||||||
await botClient.sendNotice(roomId, "Encryption keys have been refreshed.");
|
|
||||||
} else {
|
|
||||||
await botClient.sendNotice(roomId, "\u200B");
|
|
||||||
}
|
|
||||||
logger.info(`Key exchange ping sent to ${roomId}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Key exchange message failed for ${roomId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})().catch((err) => logger.error(`Key exchange loop failed: ${err}`));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Catch up missed scheduled jobs after sync is ready
|
|
||||||
dailyScheduler.catchUpMissedJobs().catch((err) => {
|
|
||||||
logger.error(`Catch-up failed: ${err}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Graceful shutdown
|
|
||||||
const shutdown = () => {
|
|
||||||
logger.info("Shutting down...");
|
|
||||||
dailyScheduler.stop();
|
|
||||||
botClient.stop();
|
|
||||||
// Delete device.json so next start creates a fresh device via password login.
|
|
||||||
// The in-memory IndexedDB crypto store doesn't persist, so reusing the same
|
|
||||||
// device ID would cause OTK conflicts with the server.
|
|
||||||
const deviceFile = path.join(dataDir, "device.json");
|
|
||||||
try { if (fs.existsSync(deviceFile)) fs.unlinkSync(deviceFile); } catch { /* ignore */ }
|
|
||||||
process.exit(0);
|
|
||||||
};
|
|
||||||
|
|
||||||
process.on("SIGINT", shutdown);
|
|
||||||
process.on("SIGTERM", shutdown);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((err) => {
|
|
||||||
logger.error(`Fatal startup error: ${err}`);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,352 +0,0 @@
|
|||||||
/**
|
|
||||||
* Compatibility wrapper around matrix-js-sdk that exposes the same interface
|
|
||||||
* our plugins expect from matrix-bot-sdk. This lets us swap the underlying
|
|
||||||
* SDK without changing any plugin code.
|
|
||||||
*/
|
|
||||||
import * as sdk from "matrix-js-sdk";
|
|
||||||
import { RoomEvent } from "matrix-js-sdk";
|
|
||||||
import type { MatrixEvent } from "matrix-js-sdk";
|
|
||||||
import type { Room } from "matrix-js-sdk";
|
|
||||||
// Suppress matrix-js-sdk's verbose HTTP logging
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
||||||
try { (require("matrix-js-sdk/lib/logger").logger as any).setLevel("warn"); } catch { /* ignore */ }
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import logger from "./utils/logger";
|
|
||||||
|
|
||||||
export interface BotClientOptions {
|
|
||||||
homeserverUrl: string;
|
|
||||||
accessToken: string;
|
|
||||||
userId: string;
|
|
||||||
dataDir: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wraps matrix-js-sdk's MatrixClient with the API surface our plugins use.
|
|
||||||
* Crypto is handled automatically by matrix-js-sdk — no manual key management.
|
|
||||||
*/
|
|
||||||
export class BotClient {
|
|
||||||
private client: sdk.MatrixClient;
|
|
||||||
private opts: BotClientOptions;
|
|
||||||
private _userId: string;
|
|
||||||
private started = false;
|
|
||||||
|
|
||||||
// Event callbacks
|
|
||||||
private messageHandlers: ((roomId: string, event: any) => Promise<void>)[] = [];
|
|
||||||
private reactionHandlers: ((roomId: string, event: any) => Promise<void>)[] = [];
|
|
||||||
|
|
||||||
private constructor(opts: BotClientOptions, deviceId: string) {
|
|
||||||
this.opts = opts;
|
|
||||||
this._userId = opts.userId;
|
|
||||||
|
|
||||||
const storeDir = path.join(opts.dataDir, "store");
|
|
||||||
if (!fs.existsSync(storeDir)) fs.mkdirSync(storeDir, { recursive: true });
|
|
||||||
|
|
||||||
this.client = sdk.createClient({
|
|
||||||
baseUrl: opts.homeserverUrl,
|
|
||||||
accessToken: opts.accessToken,
|
|
||||||
userId: opts.userId,
|
|
||||||
deviceId,
|
|
||||||
store: new sdk.MemoryStore(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a BotClient. Reads device ID from device.json (must exist).
|
|
||||||
*/
|
|
||||||
static create(opts: BotClientOptions): BotClient {
|
|
||||||
const deviceFile = path.join(opts.dataDir, "device.json");
|
|
||||||
let deviceId: string | undefined;
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(deviceFile)) {
|
|
||||||
const data = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
|
||||||
deviceId = data.deviceId;
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
if (!deviceId) {
|
|
||||||
throw new Error(
|
|
||||||
"No device.json found. The access token must be obtained via password login " +
|
|
||||||
"to create a device identity. Set MATRIX_BOT_PASSWORD."
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Using device ID: ${deviceId}`);
|
|
||||||
return new BotClient(opts, deviceId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private saveDeviceId(): void {
|
|
||||||
const deviceId = this.client.getDeviceId();
|
|
||||||
if (deviceId) {
|
|
||||||
const deviceFile = path.join(this.opts.dataDir, "device.json");
|
|
||||||
// Preserve existing fields (e.g. accessToken) when updating deviceId
|
|
||||||
let existing: Record<string, unknown> = {};
|
|
||||||
try {
|
|
||||||
if (fs.existsSync(deviceFile)) {
|
|
||||||
existing = JSON.parse(fs.readFileSync(deviceFile, "utf-8"));
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
fs.writeFileSync(deviceFile, JSON.stringify({ ...existing, deviceId }), "utf-8");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize crypto and start syncing.
|
|
||||||
*/
|
|
||||||
async start(): Promise<void> {
|
|
||||||
// Initialize rust crypto — this handles everything:
|
|
||||||
// key management, session rotation, key gossiping, device tracking
|
|
||||||
const cryptoDir = path.join(this.opts.dataDir, "crypto-js");
|
|
||||||
if (!fs.existsSync(cryptoDir)) fs.mkdirSync(cryptoDir, { recursive: true });
|
|
||||||
|
|
||||||
await this.client.initRustCrypto({
|
|
||||||
cryptoDatabasePrefix: cryptoDir + "/",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Save device ID for persistence across restarts
|
|
||||||
this.saveDeviceId();
|
|
||||||
|
|
||||||
logger.info(`E2EE initialized with device ${this.client.getDeviceId()}`);
|
|
||||||
|
|
||||||
// Bootstrap cross-signing so other clients see this device as verified by its owner
|
|
||||||
try {
|
|
||||||
const crypto = this.client.getCrypto();
|
|
||||||
if (crypto) {
|
|
||||||
await crypto.bootstrapCrossSigning({
|
|
||||||
setupNewCrossSigning: true,
|
|
||||||
authUploadDeviceSigningKeys: async (makeRequest) => {
|
|
||||||
// Authenticate the upload with UIA (empty auth for bot accounts)
|
|
||||||
await makeRequest({ type: "m.login.password", user: this.opts.userId, password: process.env.MATRIX_BOT_PASSWORD });
|
|
||||||
},
|
|
||||||
});
|
|
||||||
logger.info("Cross-signing bootstrapped — device is self-verified");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.warn(`Cross-signing bootstrap failed (non-fatal): ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wire up event listeners
|
|
||||||
this.client.on(RoomEvent.Timeline, (event: MatrixEvent, room: Room | undefined) => {
|
|
||||||
if (!room) return;
|
|
||||||
const roomId = room.roomId;
|
|
||||||
|
|
||||||
if (event.isBeingDecrypted() || event.getType() === "m.room.encrypted") {
|
|
||||||
// Wait for decryption to complete (or fail)
|
|
||||||
event.once("Event.decrypted" as any, () => {
|
|
||||||
if (event.getType() === "m.room.encrypted") {
|
|
||||||
// Decryption failed — still encrypted
|
|
||||||
logger.warn(
|
|
||||||
`Unable to decrypt event ${event.getId()} from ${event.getSender()} ` +
|
|
||||||
`in ${roomId} (session: ${(event.getContent() as any)?.session_id?.substring(0, 8)}...)`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
this.handleTimelineEvent(roomId, event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Timeout: if decryption doesn't happen within 10s, the event is likely undecryptable
|
|
||||||
setTimeout(() => {
|
|
||||||
if (event.getType() === "m.room.encrypted") {
|
|
||||||
logger.warn(
|
|
||||||
`Decryption timeout for ${event.getId()} from ${event.getSender()} in ${roomId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, 10_000);
|
|
||||||
} else {
|
|
||||||
this.handleTimelineEvent(roomId, event);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Auto-join on invite
|
|
||||||
this.client.on("RoomMember.membership" as any, (_event: any, member: any) => {
|
|
||||||
if (member.membership === "invite" && member.userId === this._userId) {
|
|
||||||
this.client.joinRoom(member.roomId).catch((err: any) => {
|
|
||||||
logger.error(`Failed to auto-join ${member.roomId}: ${err}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Register sync listener BEFORE starting client to avoid race condition
|
|
||||||
const syncReady = new Promise<void>((resolve) => {
|
|
||||||
const onSync = (state: string) => {
|
|
||||||
if (state === "PREPARED" || state === "SYNCING") {
|
|
||||||
logger.info(`Initial sync complete (state: ${state})`);
|
|
||||||
this.started = true;
|
|
||||||
this.client.removeListener("sync" as any, onSync);
|
|
||||||
resolve();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
this.client.on("sync" as any, onSync);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Start syncing
|
|
||||||
await this.client.startClient({ initialSyncLimit: 10 });
|
|
||||||
|
|
||||||
// Wait for first sync to complete
|
|
||||||
await syncReady;
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTimelineEvent(roomId: string, event: MatrixEvent): void {
|
|
||||||
const type = event.getType();
|
|
||||||
const sender = event.getSender();
|
|
||||||
const age = Date.now() - event.getTs();
|
|
||||||
|
|
||||||
logger.debug(`Timeline event: type=${type} sender=${sender} age=${Math.round(age / 1000)}s room=${roomId}`);
|
|
||||||
|
|
||||||
// Skip own messages
|
|
||||||
if (sender === this._userId) return;
|
|
||||||
// Skip non-live events (historical)
|
|
||||||
if (age > 60_000) return;
|
|
||||||
|
|
||||||
const rawEvent = {
|
|
||||||
type: event.getType(),
|
|
||||||
event_id: event.getId(),
|
|
||||||
sender: event.getSender(),
|
|
||||||
room_id: roomId,
|
|
||||||
content: event.getContent(),
|
|
||||||
origin_server_ts: event.getTs(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (event.getType() === "m.room.message") {
|
|
||||||
for (const handler of this.messageHandlers) {
|
|
||||||
handler(roomId, rawEvent).catch((err) =>
|
|
||||||
logger.error(`Message handler error: ${err}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if (event.getType() === "m.reaction") {
|
|
||||||
for (const handler of this.reactionHandlers) {
|
|
||||||
handler(roomId, rawEvent).catch((err) =>
|
|
||||||
logger.error(`Reaction handler error: ${err}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Event registration (matching matrix-bot-sdk pattern) ---
|
|
||||||
|
|
||||||
onMessage(handler: (roomId: string, event: any) => Promise<void>): void {
|
|
||||||
this.messageHandlers.push(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
onReaction(handler: (roomId: string, event: any) => Promise<void>): void {
|
|
||||||
this.reactionHandlers.push(handler);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Methods used by plugins (same signatures as matrix-bot-sdk) ---
|
|
||||||
|
|
||||||
async getUserId(): Promise<string> {
|
|
||||||
return this._userId;
|
|
||||||
}
|
|
||||||
|
|
||||||
getDeviceId(): string | null {
|
|
||||||
return this.client.getDeviceId();
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendText(roomId: string, text: string): Promise<string> {
|
|
||||||
const res = await this.client.sendTextMessage(roomId, text);
|
|
||||||
return res.event_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendNotice(roomId: string, text: string): Promise<string> {
|
|
||||||
const res = await this.client.sendNotice(roomId, text);
|
|
||||||
return res.event_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendMessage(roomId: string, content: any): Promise<string> {
|
|
||||||
const res = await this.client.sendMessage(roomId, content);
|
|
||||||
return res.event_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendEvent(roomId: string, eventType: string, content: any): Promise<string> {
|
|
||||||
const res = await this.client.sendEvent(roomId, eventType as any, content);
|
|
||||||
return res.event_id;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getEvent(roomId: string, eventId: string): Promise<any> {
|
|
||||||
return await this.client.fetchRoomEvent(roomId, eventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getJoinedRooms(): Promise<string[]> {
|
|
||||||
const res = await this.client.getJoinedRooms();
|
|
||||||
return res.joined_rooms;
|
|
||||||
}
|
|
||||||
|
|
||||||
async getJoinedRoomMembers(roomId: string): Promise<string[]> {
|
|
||||||
const room = this.client.getRoom(roomId);
|
|
||||||
if (!room) return [];
|
|
||||||
return room.getJoinedMembers().map((m) => m.userId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async joinRoom(roomId: string): Promise<void> {
|
|
||||||
await this.client.joinRoom(roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
async uploadContent(data: Buffer, contentType?: string, filename?: string): Promise<string> {
|
|
||||||
const res = await this.client.uploadContent(data, {
|
|
||||||
type: contentType,
|
|
||||||
name: filename,
|
|
||||||
});
|
|
||||||
return res.content_uri;
|
|
||||||
}
|
|
||||||
|
|
||||||
async sendToDevices(type: string, messages: Record<string, Record<string, any>>): Promise<void> {
|
|
||||||
// matrix-js-sdk expects Map<string, Map<string, object>>
|
|
||||||
const contentMap = new Map<string, Map<string, Record<string, any>>>();
|
|
||||||
for (const [userId, devices] of Object.entries(messages)) {
|
|
||||||
const deviceMap = new Map<string, Record<string, any>>();
|
|
||||||
for (const [deviceId, content] of Object.entries(devices)) {
|
|
||||||
deviceMap.set(deviceId, content);
|
|
||||||
}
|
|
||||||
contentMap.set(userId, deviceMap);
|
|
||||||
}
|
|
||||||
await this.client.sendToDevice(type, contentMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
async getOwnDevices(): Promise<any[]> {
|
|
||||||
const res = await this.client.getDevices();
|
|
||||||
return res.devices;
|
|
||||||
}
|
|
||||||
|
|
||||||
isRoomEncrypted(roomId: string): boolean {
|
|
||||||
return this.client.isRoomEncrypted(roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// DM support
|
|
||||||
dms = {
|
|
||||||
getOrCreateDm: async (userId: string): Promise<string> => {
|
|
||||||
// Check for existing DM
|
|
||||||
const rooms = this.client.getRooms();
|
|
||||||
for (const room of rooms) {
|
|
||||||
const members = room.getJoinedMembers();
|
|
||||||
if (members.length === 2 && members.some((m) => m.userId === userId)) {
|
|
||||||
return room.roomId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create new DM
|
|
||||||
const res = await this.client.createRoom({
|
|
||||||
is_direct: true,
|
|
||||||
invite: [userId],
|
|
||||||
preset: "trusted_private_chat" as any,
|
|
||||||
});
|
|
||||||
return res.room_id;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Crypto accessors for compatibility
|
|
||||||
crypto = {
|
|
||||||
isRoomEncrypted: (roomId: string): boolean => {
|
|
||||||
return this.client.isRoomEncrypted(roomId);
|
|
||||||
},
|
|
||||||
clientDeviceId: "" as string,
|
|
||||||
};
|
|
||||||
|
|
||||||
async stop(): Promise<void> {
|
|
||||||
this.client.stopClient();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Access the underlying matrix-js-sdk client for advanced operations */
|
|
||||||
get raw(): sdk.MatrixClient {
|
|
||||||
return this.client;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
interface AchievementDef {
|
|
||||||
key: string;
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
check: (stats: any, extras: AchievementExtras) => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface AchievementExtras {
|
|
||||||
userId: string;
|
|
||||||
roomId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACHIEVEMENTS: AchievementDef[] = [
|
|
||||||
{
|
|
||||||
key: "encyclopedist",
|
|
||||||
name: "Encyclopedist",
|
|
||||||
description: "Write 100,000 total words",
|
|
||||||
check: (s) => s.total_words >= 100_000,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "linkdump",
|
|
||||||
name: "Linkdump",
|
|
||||||
description: "Post 500 links",
|
|
||||||
check: (s) => s.total_links >= 500,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "night_shift",
|
|
||||||
name: "Night Shift",
|
|
||||||
description: "Send 100 messages between 00:00–04:00",
|
|
||||||
check: (s) => {
|
|
||||||
const hourly: Record<string, number> = JSON.parse(s.hourly_distribution || "{}");
|
|
||||||
const nightMsgs = [0, 1, 2, 3].reduce((sum, h) => sum + (hourly[h] ?? 0), 0);
|
|
||||||
return nightMsgs >= 100;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "riddler",
|
|
||||||
name: "Riddler",
|
|
||||||
description: "Ask 500 questions",
|
|
||||||
check: (s) => s.total_questions >= 500,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "show_dont_tell",
|
|
||||||
name: "Show Don't Tell",
|
|
||||||
description: "Post 200 images",
|
|
||||||
check: (s) => s.total_images >= 200,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "hemingway",
|
|
||||||
name: "Hemingway",
|
|
||||||
description: "Send 1,000 messages averaging under 5 words",
|
|
||||||
check: (s) => s.total_messages >= 1000 && s.avg_words_per_message < 5,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "tolstoy",
|
|
||||||
name: "Tolstoy",
|
|
||||||
description: "Send 1,000 messages averaging over 50 words",
|
|
||||||
check: (s) => s.total_messages >= 1000 && s.avg_words_per_message > 50,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "logophile",
|
|
||||||
name: "Logophile",
|
|
||||||
description: "Use a word over 15 characters",
|
|
||||||
// This is checked at message time via parseMessage().hasLongWord
|
|
||||||
// but we also check the flag here from a stored marker
|
|
||||||
check: () => false, // handled specially below
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "omnipresent",
|
|
||||||
name: "Omnipresent",
|
|
||||||
description: "Active 30 unique days in a single calendar month",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
// Check current month
|
|
||||||
const now = new Date();
|
|
||||||
const monthStart = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-01`;
|
|
||||||
const nextMonth = new Date(now.getFullYear(), now.getMonth() + 1, 1);
|
|
||||||
const monthEnd = nextMonth.toISOString().slice(0, 10);
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT COUNT(DISTINCT date) as days FROM daily_activity WHERE user_id = ? AND room_id = ? AND date >= ? AND date < ?`)
|
|
||||||
.get(extras.userId, extras.roomId, monthStart, monthEnd) as { days: number };
|
|
||||||
return row.days >= 30;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "early_bird_legend",
|
|
||||||
name: "Early Bird Legend",
|
|
||||||
description: "Held First! for 30 days total",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT COUNT(*) as firsts FROM daily_first WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { firsts: number };
|
|
||||||
return row.firsts >= 30;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "streak_week",
|
|
||||||
name: "Streak Week",
|
|
||||||
description: "Achieve a 7-day streak",
|
|
||||||
check: (s) => s.longest_streak >= 7,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "streak_month",
|
|
||||||
name: "Streak Month",
|
|
||||||
description: "Achieve a 30-day streak",
|
|
||||||
check: (s) => s.longest_streak >= 30,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "beloved",
|
|
||||||
name: "Beloved",
|
|
||||||
description: "Earn 50 reputation points",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT rep FROM users WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { rep: number } | undefined;
|
|
||||||
return (row?.rep ?? 0) >= 50;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "gamer",
|
|
||||||
name: "Gamer",
|
|
||||||
description: "Have 10 items in your backlog",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT COUNT(*) as count FROM backlog WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { count: number };
|
|
||||||
return row.count >= 10;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "completionist",
|
|
||||||
name: "Completionist",
|
|
||||||
description: "Complete 10 backlog items",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT COUNT(*) as count FROM backlog WHERE user_id = ? AND room_id = ? AND completed = 1`)
|
|
||||||
.get(extras.userId, extras.roomId) as { count: number };
|
|
||||||
return row.count >= 10;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Trivia
|
|
||||||
{
|
|
||||||
key: "trivia_first_blood",
|
|
||||||
name: "First Blood",
|
|
||||||
description: "First correct trivia answer",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT total_correct FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { total_correct: number } | undefined;
|
|
||||||
return (row?.total_correct ?? 0) >= 1;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "trivia_century",
|
|
||||||
name: "The Scholar",
|
|
||||||
description: "100 correct trivia answers",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT total_correct FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { total_correct: number } | undefined;
|
|
||||||
return (row?.total_correct ?? 0) >= 100;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "trivia_speed_demon",
|
|
||||||
name: "Speed Demon",
|
|
||||||
description: "Correct trivia answer in under 2 seconds",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT fastest_ms FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { fastest_ms: number | null } | undefined;
|
|
||||||
return (row?.fastest_ms ?? Infinity) < 2000;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "trivia_on_a_roll",
|
|
||||||
name: "On a Roll",
|
|
||||||
description: "10 correct trivia answers in a row",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT best_streak FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { best_streak: number } | undefined;
|
|
||||||
return (row?.best_streak ?? 0) >= 10;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Birthday
|
|
||||||
{
|
|
||||||
key: "birthday_celebrated",
|
|
||||||
name: "Birthday Bee",
|
|
||||||
description: "Had your birthday celebrated by Freebee",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT user_id FROM birthday_fired WHERE user_id = ?`)
|
|
||||||
.get(extras.userId) as any;
|
|
||||||
return !!row;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// LLM passive (only earn if LLM is enabled)
|
|
||||||
{
|
|
||||||
key: "wotd_scholar",
|
|
||||||
name: "Word Nerd",
|
|
||||||
description: "Used the WOTD correctly 10 times",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT COUNT(*) as c FROM wotd_usage WHERE user_id = ? AND room_id = ? AND xp_awarded > 0`)
|
|
||||||
.get(extras.userId, extras.roomId) as { c: number };
|
|
||||||
return row.c >= 10;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "wotd_cheater",
|
|
||||||
name: "Nice Try",
|
|
||||||
description: "Attempted to game the WOTD 5 times",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT COUNT(*) as c FROM wotd_usage WHERE user_id = ? AND room_id = ? AND xp_awarded = 0`)
|
|
||||||
.get(extras.userId, extras.roomId) as { c: number };
|
|
||||||
return row.c >= 5;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "potty_bronze",
|
|
||||||
name: "Needs Soap",
|
|
||||||
description: "50 profanity detections",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT total FROM potty_mouth WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { total: number } | undefined;
|
|
||||||
return (row?.total ?? 0) >= 50;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "potty_gold",
|
|
||||||
name: "Sailor Mouth",
|
|
||||||
description: "500 profanity detections",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT total FROM potty_mouth WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { total: number } | undefined;
|
|
||||||
return (row?.total ?? 0) >= 500;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "roaster",
|
|
||||||
name: "The Roaster",
|
|
||||||
description: "50 insults thrown",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT total_thrown FROM insult_log WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { total_thrown: number } | undefined;
|
|
||||||
return (row?.total_thrown ?? 0) >= 50;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "punching_bag",
|
|
||||||
name: "Punching Bag",
|
|
||||||
description: "Targeted 50 times",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT times_targeted FROM insult_log WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { times_targeted: number } | undefined;
|
|
||||||
return (row?.times_targeted ?? 0) >= 50;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
// Social / utility
|
|
||||||
{
|
|
||||||
key: "welcome_wagon",
|
|
||||||
name: "Welcome Wagon",
|
|
||||||
description: "First message ever in this room",
|
|
||||||
check: () => false, // handled by WelcomePlugin directly
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "countdown_keeper",
|
|
||||||
name: "Countdown Keeper",
|
|
||||||
description: "5 active countdowns simultaneously",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT COUNT(*) as c FROM countdowns WHERE user_id = ? AND room_id = ? AND completed = 0`)
|
|
||||||
.get(extras.userId, extras.roomId) as { c: number };
|
|
||||||
return row.c >= 5;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "markov_victim",
|
|
||||||
name: "Markov Victim",
|
|
||||||
description: "Had someone run !markov on you",
|
|
||||||
check: () => false, // handled by MarkovPlugin directly
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "stonks",
|
|
||||||
name: "Stonks",
|
|
||||||
description: "5 tickers on stock watchlist",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT COUNT(*) as c FROM stock_watchlist WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { c: number };
|
|
||||||
return row.c >= 5;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "weeaboo",
|
|
||||||
name: "Certified Weeaboo",
|
|
||||||
description: "10 anime on watchlist",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT COUNT(*) as c FROM anime_watchlist WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { c: number };
|
|
||||||
return row.c >= 10;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "cinephile",
|
|
||||||
name: "Cinephile",
|
|
||||||
description: "10 movies/TV shows on watchlist",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT COUNT(*) as c FROM movie_watchlist WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { c: number };
|
|
||||||
return row.c >= 10;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: "concert_goer",
|
|
||||||
name: "Concert Goer",
|
|
||||||
description: "Watching 5 artists",
|
|
||||||
check: (_s, extras) => {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(`SELECT COUNT(*) as c FROM concert_watchlist WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(extras.userId, extras.roomId) as { c: number };
|
|
||||||
return row.c >= 5;
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export class AchievementsPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "achievements";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "achievements", description: "View unlocked achievements", usage: "!achievements [@user]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
// Passive: evaluate achievements silently
|
|
||||||
this.evaluateAchievements(ctx.sender, ctx.roomId, ctx.body);
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "achievements")) {
|
|
||||||
await this.handleAchievements(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private evaluateAchievements(userId: string, roomId: string, messageBody: string): void {
|
|
||||||
const db = getDb();
|
|
||||||
const stats = db
|
|
||||||
.prepare(`SELECT * FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as any;
|
|
||||||
|
|
||||||
if (!stats) return;
|
|
||||||
|
|
||||||
const extras: AchievementExtras = { userId, roomId };
|
|
||||||
|
|
||||||
for (const achievement of ACHIEVEMENTS) {
|
|
||||||
// Skip if already unlocked
|
|
||||||
const existing = db
|
|
||||||
.prepare(`SELECT id FROM achievements WHERE user_id = ? AND room_id = ? AND achievement_key = ?`)
|
|
||||||
.get(userId, roomId, achievement.key);
|
|
||||||
if (existing) continue;
|
|
||||||
|
|
||||||
let earned = false;
|
|
||||||
|
|
||||||
// Special case: logophile checks message content directly
|
|
||||||
if (achievement.key === "logophile") {
|
|
||||||
const { parseMessage } = require("../utils/parser");
|
|
||||||
const parsed = parseMessage(messageBody);
|
|
||||||
earned = parsed.hasLongWord;
|
|
||||||
} else {
|
|
||||||
earned = achievement.check(stats, extras);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (earned) {
|
|
||||||
db.prepare(`INSERT OR IGNORE INTO achievements (user_id, room_id, achievement_key) VALUES (?, ?, ?)`).run(
|
|
||||||
userId,
|
|
||||||
roomId,
|
|
||||||
achievement.key
|
|
||||||
);
|
|
||||||
logger.debug(`Achievement unlocked: ${userId} earned "${achievement.key}" in ${roomId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleAchievements(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "achievements");
|
|
||||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const unlocked = db
|
|
||||||
.prepare(`SELECT achievement_key, unlocked_at FROM achievements WHERE user_id = ? AND room_id = ? ORDER BY unlocked_at ASC`)
|
|
||||||
.all(targetUser, ctx.roomId) as { achievement_key: string; unlocked_at: string }[];
|
|
||||||
|
|
||||||
const unlockedKeys = new Set(unlocked.map((a) => a.achievement_key));
|
|
||||||
|
|
||||||
const lines = [`Achievements for ${targetUser} (${unlocked.length}/${ACHIEVEMENTS.length}):`];
|
|
||||||
|
|
||||||
for (const def of ACHIEVEMENTS) {
|
|
||||||
if (unlockedKeys.has(def.key)) {
|
|
||||||
lines.push(` [x] ${def.name} — ${def.description}`);
|
|
||||||
} else {
|
|
||||||
lines.push(` [ ] ${def.name} — ${def.description}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,429 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
interface JikanAnime {
|
|
||||||
mal_id: number;
|
|
||||||
title: string;
|
|
||||||
score: number | null;
|
|
||||||
episodes: number | null;
|
|
||||||
status: string | null;
|
|
||||||
synopsis: string | null;
|
|
||||||
genres: { name: string }[];
|
|
||||||
broadcast: { day: string | null; time: string | null; string: string | null } | null;
|
|
||||||
aired: { from: string | null; to: string | null; string: string | null } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function jikanDelay(): Promise<void> {
|
|
||||||
await new Promise((r) => setTimeout(r, 400));
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AnimePlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "anime";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "anime search", description: "Search anime by title", usage: "!anime search <title>" },
|
|
||||||
{ name: "anime watch", description: "Add anime to your watchlist", usage: "!anime watch <title>" },
|
|
||||||
{ name: "anime watching", description: "List your anime watchlist", usage: "!anime watching" },
|
|
||||||
{ name: "anime unwatch", description: "Remove anime from watchlist", usage: "!anime unwatch <title|id>" },
|
|
||||||
{ name: "anime season", description: "Current season top 10 by score", usage: "!anime season" },
|
|
||||||
{ name: "anime upcoming", description: "Next season preview top 10", usage: "!anime upcoming" },
|
|
||||||
{ name: "anime", description: "Get anime details", usage: "!anime <title or MAL ID>" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "anime search ")) {
|
|
||||||
await this.handleSearch(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "anime watch ")) {
|
|
||||||
await this.handleWatch(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "anime watching")) {
|
|
||||||
await this.handleWatching(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "anime unwatch ")) {
|
|
||||||
await this.handleUnwatch(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "anime season")) {
|
|
||||||
await this.handleSeason(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "anime upcoming")) {
|
|
||||||
await this.handleUpcoming(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "anime ")) {
|
|
||||||
await this.handleDetails(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Jikan helpers ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
private getCachedAnime(malId: number): JikanAnime | null {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT data FROM anime_cache WHERE mal_id = ? AND cached_at > unixepoch() - 86400`)
|
|
||||||
.get(malId) as { data: string } | undefined;
|
|
||||||
|
|
||||||
if (!row) return null;
|
|
||||||
try {
|
|
||||||
return JSON.parse(row.data) as JikanAnime;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private cacheAnime(anime: JikanAnime): void {
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO anime_cache (mal_id, data, cached_at) VALUES (?, ?, unixepoch())
|
|
||||||
ON CONFLICT(mal_id) DO UPDATE SET data = excluded.data, cached_at = excluded.cached_at`
|
|
||||||
).run(anime.mal_id, JSON.stringify(anime));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchAnimeById(malId: number): Promise<JikanAnime | null> {
|
|
||||||
const cached = this.getCachedAnime(malId);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
try {
|
|
||||||
await jikanDelay();
|
|
||||||
const res = await fetch(`https://api.jikan.moe/v4/anime/${malId}`);
|
|
||||||
if (!res.ok) return null;
|
|
||||||
|
|
||||||
const json = (await res.json()) as any;
|
|
||||||
const anime = json.data as JikanAnime;
|
|
||||||
if (!anime) return null;
|
|
||||||
|
|
||||||
this.cacheAnime(anime);
|
|
||||||
return anime;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Jikan fetch by ID ${malId} failed: ${err}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async searchAnime(query: string, limit = 3): Promise<JikanAnime[]> {
|
|
||||||
try {
|
|
||||||
await jikanDelay();
|
|
||||||
const url = `https://api.jikan.moe/v4/anime?q=${encodeURIComponent(query)}&limit=${limit}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
|
|
||||||
const json = (await res.json()) as any;
|
|
||||||
const results = (json.data ?? []) as JikanAnime[];
|
|
||||||
|
|
||||||
for (const anime of results) {
|
|
||||||
this.cacheAnime(anime);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Jikan search failed for "${query}": ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Command handlers ───────────────────────────────────────────────
|
|
||||||
|
|
||||||
private async handleSearch(ctx: MessageContext): Promise<void> {
|
|
||||||
const query = this.getArgs(ctx.body, "anime search").trim();
|
|
||||||
if (!query) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !anime search <title>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await this.searchAnime(query, 3);
|
|
||||||
if (results.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No anime found for "${query}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [`Search results for "${query}":`];
|
|
||||||
for (const anime of results) {
|
|
||||||
const score = anime.score != null ? anime.score.toFixed(2) : "N/A";
|
|
||||||
const episodes = anime.episodes != null ? String(anime.episodes) : "?";
|
|
||||||
const status = anime.status ?? "Unknown";
|
|
||||||
lines.push(`[${anime.mal_id}] ${anime.title} — Score: ${score} | Episodes: ${episodes} | Status: ${status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleDetails(ctx: MessageContext): Promise<void> {
|
|
||||||
const query = this.getArgs(ctx.body, "anime").trim();
|
|
||||||
if (!query) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !anime <title or MAL ID>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let anime: JikanAnime | null = null;
|
|
||||||
|
|
||||||
if (/^\d+$/.test(query)) {
|
|
||||||
anime = await this.fetchAnimeById(parseInt(query, 10));
|
|
||||||
} else {
|
|
||||||
const results = await this.searchAnime(query, 1);
|
|
||||||
if (results.length > 0) {
|
|
||||||
anime = results[0];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!anime) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No anime found for "${query}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const score = anime.score != null ? anime.score.toFixed(2) : "N/A";
|
|
||||||
const episodes = anime.episodes != null ? String(anime.episodes) : "?";
|
|
||||||
const genres = anime.genres.map((g) => g.name).join(", ") || "N/A";
|
|
||||||
let synopsis = anime.synopsis ?? "No synopsis available.";
|
|
||||||
if (synopsis.length > 300) {
|
|
||||||
synopsis = synopsis.slice(0, 297) + "...";
|
|
||||||
}
|
|
||||||
|
|
||||||
const status = anime.status ?? "Unknown";
|
|
||||||
let airingInfo = `Status: ${status}`;
|
|
||||||
if (anime.broadcast?.string) {
|
|
||||||
airingInfo += ` | Broadcast: ${anime.broadcast.string}`;
|
|
||||||
} else if (anime.aired?.string) {
|
|
||||||
airingInfo += ` | Aired: ${anime.aired.string}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
`${anime.title} [MAL ${anime.mal_id}]`,
|
|
||||||
`Score: ${score} | Episodes: ${episodes}`,
|
|
||||||
`Genres: ${genres}`,
|
|
||||||
airingInfo,
|
|
||||||
``,
|
|
||||||
synopsis,
|
|
||||||
];
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWatch(ctx: MessageContext): Promise<void> {
|
|
||||||
const query = this.getArgs(ctx.body, "anime watch").trim();
|
|
||||||
if (!query) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !anime watch <title>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const results = await this.searchAnime(query, 1);
|
|
||||||
if (results.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No anime found for "${query}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const anime = results[0];
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const existing = db
|
|
||||||
.prepare(`SELECT id FROM anime_watchlist WHERE user_id = ? AND room_id = ? AND mal_id = ?`)
|
|
||||||
.get(ctx.sender, ctx.roomId, anime.mal_id);
|
|
||||||
|
|
||||||
if (existing) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `"${anime.title}" is already on your watchlist.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const airingDate = anime.aired?.from ?? null;
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO anime_watchlist (user_id, room_id, mal_id, title, airing_date) VALUES (?, ?, ?, ?, ?)`
|
|
||||||
).run(ctx.sender, ctx.roomId, anime.mal_id, anime.title, airingDate);
|
|
||||||
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Added "${anime.title}" to your watchlist.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWatching(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT mal_id, title FROM anime_watchlist WHERE user_id = ? AND room_id = ? ORDER BY created_at DESC`)
|
|
||||||
.all(ctx.sender, ctx.roomId) as { mal_id: number; title: string }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your anime watchlist is empty.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = ["Your anime watchlist:"];
|
|
||||||
for (const row of rows) {
|
|
||||||
const cached = this.getCachedAnime(row.mal_id);
|
|
||||||
const status = cached?.status ?? "Unknown";
|
|
||||||
lines.push(`[${row.mal_id}] ${row.title} — ${status}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleUnwatch(ctx: MessageContext): Promise<void> {
|
|
||||||
const query = this.getArgs(ctx.body, "anime unwatch").trim();
|
|
||||||
if (!query) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !anime unwatch <title|id>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
let result;
|
|
||||||
|
|
||||||
if (/^\d+$/.test(query)) {
|
|
||||||
result = db
|
|
||||||
.prepare(`DELETE FROM anime_watchlist WHERE user_id = ? AND room_id = ? AND mal_id = ?`)
|
|
||||||
.run(ctx.sender, ctx.roomId, parseInt(query, 10));
|
|
||||||
} else {
|
|
||||||
result = db
|
|
||||||
.prepare(`DELETE FROM anime_watchlist WHERE user_id = ? AND room_id = ? AND title LIKE ?`)
|
|
||||||
.run(ctx.sender, ctx.roomId, `%${query}%`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.changes > 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Removed ${result.changes} anime from your watchlist.`);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No matching anime found on your watchlist.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSeason(ctx: MessageContext): Promise<void> {
|
|
||||||
try {
|
|
||||||
await jikanDelay();
|
|
||||||
const res = await fetch(`https://api.jikan.moe/v4/seasons/now?limit=10&order_by=score&sort=desc`);
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch current season data.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = (await res.json()) as any;
|
|
||||||
const animeList = (json.data ?? []) as JikanAnime[];
|
|
||||||
|
|
||||||
if (animeList.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No current season anime found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const anime of animeList) {
|
|
||||||
this.cacheAnime(anime);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = ["Current Season — Top 10 by Score:"];
|
|
||||||
for (let i = 0; i < animeList.length; i++) {
|
|
||||||
const anime = animeList[i];
|
|
||||||
const score = anime.score != null ? anime.score.toFixed(2) : "N/A";
|
|
||||||
const episodes = anime.episodes != null ? String(anime.episodes) : "?";
|
|
||||||
lines.push(`${i + 1}. ${anime.title} — Score: ${score} | Episodes: ${episodes}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Season fetch failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch season data. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleUpcoming(ctx: MessageContext): Promise<void> {
|
|
||||||
try {
|
|
||||||
await jikanDelay();
|
|
||||||
const res = await fetch(`https://api.jikan.moe/v4/seasons/upcoming?limit=10&order_by=members&sort=desc`);
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch upcoming season data.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const json = (await res.json()) as any;
|
|
||||||
const animeList = (json.data ?? []) as JikanAnime[];
|
|
||||||
|
|
||||||
if (animeList.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No upcoming anime found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const anime of animeList) {
|
|
||||||
this.cacheAnime(anime);
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = ["Upcoming Season — Top 10 by Popularity:"];
|
|
||||||
for (let i = 0; i < animeList.length; i++) {
|
|
||||||
const anime = animeList[i];
|
|
||||||
const score = anime.score != null ? anime.score.toFixed(2) : "N/A";
|
|
||||||
const episodes = anime.episodes != null ? String(anime.episodes) : "?";
|
|
||||||
lines.push(`${i + 1}. ${anime.title} — Score: ${score} | Episodes: ${episodes}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Upcoming fetch failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch upcoming data. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Scheduled daily releases ───────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by DailyScheduler at 19:30 UTC to post today's airing watchlisted anime.
|
|
||||||
*/
|
|
||||||
async postDailyReleases(botRooms: string[]): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
||||||
const todayName = dayNames[new Date().getUTCDay()];
|
|
||||||
|
|
||||||
// Get all unique mal_ids from watchlist that haven't been notified
|
|
||||||
const watchedIds = db
|
|
||||||
.prepare(`SELECT DISTINCT mal_id FROM anime_watchlist WHERE notified = 0`)
|
|
||||||
.all() as { mal_id: number }[];
|
|
||||||
|
|
||||||
if (watchedIds.length === 0) return;
|
|
||||||
|
|
||||||
const airingToday: JikanAnime[] = [];
|
|
||||||
|
|
||||||
for (const { mal_id } of watchedIds) {
|
|
||||||
const anime = await this.fetchAnimeById(mal_id);
|
|
||||||
if (!anime) continue;
|
|
||||||
|
|
||||||
if (anime.status !== "Currently Airing") continue;
|
|
||||||
|
|
||||||
const broadcastDay = anime.broadcast?.day ?? null;
|
|
||||||
if (broadcastDay && broadcastDay.replace(/s$/, "") === todayName.replace(/s$/, "")) {
|
|
||||||
airingToday.push(anime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (airingToday.length === 0) return;
|
|
||||||
|
|
||||||
// Build summary message
|
|
||||||
const lines = ["Today's Airing Anime (from watchlists):"];
|
|
||||||
for (const anime of airingToday) {
|
|
||||||
const time = anime.broadcast?.time ? ` at ${anime.broadcast.time} JST` : "";
|
|
||||||
lines.push(`- ${anime.title}${time}`);
|
|
||||||
}
|
|
||||||
const summary = lines.join("\n");
|
|
||||||
|
|
||||||
// Post to each bot room
|
|
||||||
for (const roomId of botRooms) {
|
|
||||||
try {
|
|
||||||
await this.sendMessage(roomId, summary);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to post anime releases to ${roomId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DM users who have these shows on their watchlist
|
|
||||||
const dmSent = new Set<string>();
|
|
||||||
|
|
||||||
for (const anime of airingToday) {
|
|
||||||
const watchers = db
|
|
||||||
.prepare(`SELECT DISTINCT user_id FROM anime_watchlist WHERE mal_id = ? AND notified = 0`)
|
|
||||||
.all(anime.mal_id) as { user_id: string }[];
|
|
||||||
|
|
||||||
for (const { user_id } of watchers) {
|
|
||||||
const key = `${user_id}:${anime.mal_id}`;
|
|
||||||
if (dmSent.has(key)) continue;
|
|
||||||
dmSent.add(key);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const time = anime.broadcast?.time ? ` at ${anime.broadcast.time} JST` : "";
|
|
||||||
await this.sendDm(user_id, `"${anime.title}" airs today${time}!`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to DM anime notification to ${user_id}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,218 +0,0 @@
|
|||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
/** Common client interface — works with both matrix-bot-sdk and our BotClient wrapper */
|
|
||||||
export interface IMatrixClient {
|
|
||||||
getUserId(): Promise<string> | string | null;
|
|
||||||
sendText(roomId: string, text: string): Promise<string>;
|
|
||||||
sendMessage(roomId: string, content: any): Promise<string>;
|
|
||||||
sendEvent(roomId: string, eventType: string, content: any): Promise<string>;
|
|
||||||
sendNotice(roomId: string, text: string): Promise<string>;
|
|
||||||
getEvent(roomId: string, eventId: string): Promise<any>;
|
|
||||||
getJoinedRooms(): Promise<string[]>;
|
|
||||||
getJoinedRoomMembers(roomId: string): Promise<string[]>;
|
|
||||||
uploadContent(data: Buffer, contentType?: string, filename?: string): Promise<string>;
|
|
||||||
dms: { getOrCreateDm(userId: string): Promise<string> };
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CommandDef {
|
|
||||||
name: string;
|
|
||||||
description: string;
|
|
||||||
usage?: string;
|
|
||||||
adminOnly?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MessageContext {
|
|
||||||
roomId: string;
|
|
||||||
sender: string;
|
|
||||||
body: string;
|
|
||||||
eventId: string;
|
|
||||||
event: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ReactionContext {
|
|
||||||
roomId: string;
|
|
||||||
sender: string;
|
|
||||||
eventId: string;
|
|
||||||
reactionKey: string;
|
|
||||||
targetEventId: string;
|
|
||||||
event: any;
|
|
||||||
}
|
|
||||||
|
|
||||||
export abstract class Plugin {
|
|
||||||
protected client: IMatrixClient;
|
|
||||||
protected prefix: string;
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
this.client = client;
|
|
||||||
this.prefix = process.env.BOT_PREFIX ?? "!";
|
|
||||||
}
|
|
||||||
|
|
||||||
abstract get name(): string;
|
|
||||||
abstract get commands(): CommandDef[];
|
|
||||||
|
|
||||||
abstract onMessage(ctx: MessageContext): Promise<void>;
|
|
||||||
|
|
||||||
async onReaction(_ctx: ReactionContext): Promise<void> {
|
|
||||||
// Default no-op; plugins override if they care about reactions
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async sendMessage(roomId: string, text: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await this.client.sendText(roomId, text);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to send message to ${roomId}: ${err}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async sendHtml(roomId: string, html: string, plain?: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await this.client.sendMessage(roomId, {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: plain ?? html.replace(/<[^>]+>/g, ""),
|
|
||||||
format: "org.matrix.custom.html",
|
|
||||||
formatted_body: html,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to send HTML message to ${roomId}: ${err}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async sendReply(roomId: string, eventId: string, text: string): Promise<string> {
|
|
||||||
try {
|
|
||||||
return await this.client.sendMessage(roomId, {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: text,
|
|
||||||
"m.relates_to": {
|
|
||||||
"m.in_reply_to": { event_id: eventId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to send reply in ${roomId}: ${err}`);
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected async sendDm(userId: string, text: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const dmRoomId = await this.client.dms.getOrCreateDm(userId);
|
|
||||||
await this.client.sendText(dmRoomId, text);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to send DM to ${userId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a reaction with automatic retry on transient failures.
|
|
||||||
*/
|
|
||||||
protected sendReact(roomId: string, eventId: string, emoji: string): void {
|
|
||||||
this.sendWithRetry(
|
|
||||||
() => this.client.sendEvent(roomId, "m.reaction", {
|
|
||||||
"m.relates_to": { rel_type: "m.annotation", event_id: eventId, key: emoji },
|
|
||||||
}),
|
|
||||||
`react ${emoji} in ${roomId}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fire-and-forget with retry on connection errors. Retries up to 2 times
|
|
||||||
* with 2s/4s delays. Non-connection errors are not retried.
|
|
||||||
*/
|
|
||||||
protected sendWithRetry(fn: () => Promise<any>, label: string, maxRetries = 2): void {
|
|
||||||
const attempt = (retryCount: number) => {
|
|
||||||
fn().catch((err) => {
|
|
||||||
const isTransient = String(err).includes("ConnectionError") || String(err).includes("fetch failed");
|
|
||||||
if (isTransient && retryCount < maxRetries) {
|
|
||||||
const delay = 2000 * (retryCount + 1);
|
|
||||||
setTimeout(() => attempt(retryCount + 1), delay);
|
|
||||||
} else {
|
|
||||||
logger.error(`Failed to ${label}: ${err}`);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
attempt(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected isCommand(body: string, command: string): boolean {
|
|
||||||
return body.startsWith(this.prefix + command);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected getArgs(body: string, command: string): string {
|
|
||||||
return body.slice(this.prefix.length + command.length).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
protected isAdmin(userId: string): boolean {
|
|
||||||
const admins = (process.env.BOT_ADMIN_USERS ?? "").split(",").map((s) => s.trim());
|
|
||||||
return admins.includes(userId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export class PluginRegistry {
|
|
||||||
private plugins: Plugin[] = [];
|
|
||||||
private botUserId: string;
|
|
||||||
|
|
||||||
constructor(botUserId: string) {
|
|
||||||
this.botUserId = botUserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
register(plugin: Plugin): void {
|
|
||||||
this.plugins.push(plugin);
|
|
||||||
logger.info(`Registered plugin: ${plugin.name} (${plugin.commands.map((c) => c.name).join(", ") || "passive"})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getCommands(): { plugin: string; commands: CommandDef[] }[] {
|
|
||||||
return this.plugins
|
|
||||||
.filter((p) => p.commands.length > 0)
|
|
||||||
.map((p) => ({ plugin: p.name, commands: p.commands }));
|
|
||||||
}
|
|
||||||
|
|
||||||
async dispatch(roomId: string, event: any): Promise<void> {
|
|
||||||
if (event.type !== "m.room.message") return;
|
|
||||||
const content = event.content;
|
|
||||||
if (!content || content.msgtype !== "m.text") return;
|
|
||||||
|
|
||||||
const sender: string = event.sender;
|
|
||||||
if (sender === this.botUserId) return;
|
|
||||||
|
|
||||||
const body: string = content.body ?? "";
|
|
||||||
const eventId: string = event.event_id;
|
|
||||||
|
|
||||||
const ctx: MessageContext = { roomId, sender, body, eventId, event };
|
|
||||||
|
|
||||||
for (const plugin of this.plugins) {
|
|
||||||
try {
|
|
||||||
await plugin.onMessage(ctx);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Plugin ${plugin.name} error on message: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async dispatchReaction(roomId: string, event: any): Promise<void> {
|
|
||||||
if (event.type !== "m.reaction") return;
|
|
||||||
|
|
||||||
const sender: string = event.sender;
|
|
||||||
if (sender === this.botUserId) return;
|
|
||||||
|
|
||||||
const relatesTo = event.content?.["m.relates_to"];
|
|
||||||
if (!relatesTo) return;
|
|
||||||
|
|
||||||
const ctx: ReactionContext = {
|
|
||||||
roomId,
|
|
||||||
sender,
|
|
||||||
eventId: event.event_id,
|
|
||||||
reactionKey: relatesTo.key ?? "",
|
|
||||||
targetEventId: relatesTo.event_id ?? "",
|
|
||||||
event,
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const plugin of this.plugins) {
|
|
||||||
try {
|
|
||||||
await plugin.onReaction(ctx);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Plugin ${plugin.name} error on reaction: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,249 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { XpPlugin } from "./xp";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
||||||
];
|
|
||||||
|
|
||||||
export class BirthdayPlugin extends Plugin {
|
|
||||||
private xpPlugin: XpPlugin;
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
|
||||||
super(client);
|
|
||||||
this.xpPlugin = xpPlugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "birthday";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "birthday set", description: "Set your birthday", usage: "!birthday set <month> <day> [year]" },
|
|
||||||
{ name: "birthday remove", description: "Remove your birthday", usage: "!birthday remove" },
|
|
||||||
{ name: "birthday", description: "Show a birthday", usage: "!birthday [@user]" },
|
|
||||||
{ name: "birthdays", description: "Upcoming birthdays in the next 30 days", usage: "!birthdays" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "birthdays")) {
|
|
||||||
await this.handleUpcoming(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "birthday set")) {
|
|
||||||
await this.handleSet(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "birthday remove")) {
|
|
||||||
await this.handleRemove(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "birthday")) {
|
|
||||||
await this.handleShow(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSet(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "birthday set").trim().split(/\s+/);
|
|
||||||
|
|
||||||
if (args.length < 2) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !birthday set <month> <day> [year]\nExample: !birthday set 3 15 1990");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const month = parseInt(args[0]);
|
|
||||||
const day = parseInt(args[1]);
|
|
||||||
const year = args[2] ? parseInt(args[2]) : null;
|
|
||||||
|
|
||||||
if (isNaN(month) || month < 1 || month > 12) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Month must be between 1 and 12.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isNaN(day) || day < 1 || day > 31) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Day must be between 1 and 31.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (year !== null && isNaN(year)) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Invalid year.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO birthdays (user_id, room_id, month, day, year)
|
|
||||||
VALUES (?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
|
||||||
month = excluded.month,
|
|
||||||
day = excluded.day,
|
|
||||||
year = excluded.year
|
|
||||||
`).run(ctx.sender, ctx.roomId, month, day, year);
|
|
||||||
|
|
||||||
const label = `${MONTH_NAMES[month - 1]} ${day}${year ? `, ${year}` : ""}`;
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Birthday set to ${label}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRemove(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const result = db
|
|
||||||
.prepare(`DELETE FROM birthdays WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.run(ctx.sender, ctx.roomId);
|
|
||||||
|
|
||||||
if (result.changes > 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your birthday has been removed.");
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "You don't have a birthday set in this room.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleShow(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "birthday").trim();
|
|
||||||
const target = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT month, day, year FROM birthdays WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(target, ctx.roomId) as { month: number; day: number; year: number | null } | undefined;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No birthday set for ${target}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let message = `${target}'s birthday: ${MONTH_NAMES[row.month - 1]} ${row.day}`;
|
|
||||||
|
|
||||||
// Only show year/age to the user themselves
|
|
||||||
if (ctx.sender === target && row.year) {
|
|
||||||
const now = new Date();
|
|
||||||
const age = this.calculateAge(row.year, row.month, row.day, now);
|
|
||||||
message += `, ${row.year} (age ${age})`;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleUpcoming(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT user_id, month, day FROM birthdays WHERE room_id = ?`)
|
|
||||||
.all(ctx.roomId) as { user_id: string; month: number; day: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No birthdays set in this room.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const today = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
|
|
||||||
|
|
||||||
interface UpcomingEntry {
|
|
||||||
userId: string;
|
|
||||||
month: number;
|
|
||||||
day: number;
|
|
||||||
daysUntil: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const upcoming: UpcomingEntry[] = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
let nextOccurrence = new Date(Date.UTC(today.getUTCFullYear(), row.month - 1, row.day));
|
|
||||||
if (nextOccurrence < today) {
|
|
||||||
nextOccurrence = new Date(Date.UTC(today.getUTCFullYear() + 1, row.month - 1, row.day));
|
|
||||||
}
|
|
||||||
|
|
||||||
const diffMs = nextOccurrence.getTime() - today.getTime();
|
|
||||||
const daysUntil = Math.round(diffMs / (1000 * 60 * 60 * 24));
|
|
||||||
|
|
||||||
if (daysUntil <= 30) {
|
|
||||||
upcoming.push({ userId: row.user_id, month: row.month, day: row.day, daysUntil });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (upcoming.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No birthdays in the next 30 days.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
upcoming.sort((a, b) => a.daysUntil - b.daysUntil);
|
|
||||||
|
|
||||||
const lines = upcoming.map((entry) => {
|
|
||||||
return `${MONTH_NAMES[entry.month - 1]} ${entry.day} — ${entry.userId}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, `Upcoming birthdays (next 30 days):\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by DailyScheduler to post birthday announcements.
|
|
||||||
*/
|
|
||||||
async checkAndPost(botRooms: string[]): Promise<void> {
|
|
||||||
const now = new Date();
|
|
||||||
const currentMonth = now.getUTCMonth() + 1;
|
|
||||||
const currentDay = now.getUTCDate();
|
|
||||||
const currentYear = now.getUTCFullYear();
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const matches = db
|
|
||||||
.prepare(`SELECT user_id, room_id, year FROM birthdays WHERE month = ? AND day = ?`)
|
|
||||||
.all(currentMonth, currentDay) as { user_id: string; room_id: string; year: number | null }[];
|
|
||||||
|
|
||||||
if (matches.length === 0) return;
|
|
||||||
|
|
||||||
// Group by user to avoid duplicate DMs
|
|
||||||
const dmSent = new Set<string>();
|
|
||||||
|
|
||||||
for (const match of matches) {
|
|
||||||
// Only post in rooms the bot is active in
|
|
||||||
if (!botRooms.includes(match.room_id)) continue;
|
|
||||||
|
|
||||||
// Check if already fired for this user this year
|
|
||||||
const fired = db
|
|
||||||
.prepare(`SELECT 1 FROM birthday_fired WHERE user_id = ? AND year = ?`)
|
|
||||||
.get(match.user_id, currentYear);
|
|
||||||
|
|
||||||
if (fired) continue;
|
|
||||||
|
|
||||||
// Build announcement
|
|
||||||
let announcement: string;
|
|
||||||
if (match.year) {
|
|
||||||
const age = this.calculateAge(match.year, currentMonth, currentDay, now);
|
|
||||||
announcement = `Happy Birthday ${match.user_id} — turning ${age} today!`;
|
|
||||||
} else {
|
|
||||||
announcement = `Happy Birthday ${match.user_id}!`;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.sendMessage(match.room_id, announcement);
|
|
||||||
this.xpPlugin.grantXp(match.user_id, match.room_id, 100, "birthday");
|
|
||||||
logger.info(`Posted birthday for ${match.user_id} in ${match.room_id}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to post birthday for ${match.user_id} in ${match.room_id}: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send DM once per user
|
|
||||||
if (!dmSent.has(match.user_id)) {
|
|
||||||
dmSent.add(match.user_id);
|
|
||||||
try {
|
|
||||||
await this.sendDm(match.user_id, "Happy Birthday! Hope you have an amazing day!");
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to DM birthday user ${match.user_id}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark as fired for this year
|
|
||||||
db.prepare(`INSERT OR IGNORE INTO birthday_fired (user_id, year) VALUES (?, ?)`).run(
|
|
||||||
match.user_id,
|
|
||||||
currentYear
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private calculateAge(birthYear: number, birthMonth: number, birthDay: number, now: Date): number {
|
|
||||||
let age = now.getUTCFullYear() - birthYear;
|
|
||||||
const monthDiff = (now.getUTCMonth() + 1) - birthMonth;
|
|
||||||
if (monthDiff < 0 || (monthDiff === 0 && now.getUTCDate() < birthDay)) {
|
|
||||||
age--;
|
|
||||||
}
|
|
||||||
return age;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import fs from "fs";
|
|
||||||
import path from "path";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const LLM_ENABLED = (process.env.OLLAMA_HOST ?? "") !== "" && (process.env.OLLAMA_MODEL ?? "") !== "";
|
|
||||||
const OLLAMA_HOST = (() => {
|
|
||||||
const raw = process.env.OLLAMA_HOST ?? "";
|
|
||||||
return raw && !raw.startsWith("http") ? `http://${raw}` : raw;
|
|
||||||
})();
|
|
||||||
const startTime = Date.now();
|
|
||||||
let messagesProcessed = 0;
|
|
||||||
|
|
||||||
export function incrementMessageCount(): void {
|
|
||||||
messagesProcessed++;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class BotInfoPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "botinfo";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "botinfo", description: "Bot diagnostics", adminOnly: true },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
incrementMessageCount();
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "botinfo")) {
|
|
||||||
await this.handleBotInfo(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleBotInfo(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!this.isAdmin(ctx.sender)) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Admin only.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const uptimeMs = Date.now() - startTime;
|
|
||||||
const uptime = this.formatUptime(uptimeMs);
|
|
||||||
|
|
||||||
// DB size
|
|
||||||
const dataDir = process.env.DATA_DIR ?? "./data";
|
|
||||||
const dbPath = path.join(dataDir, "freebee.db");
|
|
||||||
let dbSize = "unknown";
|
|
||||||
try {
|
|
||||||
const stats = fs.statSync(dbPath);
|
|
||||||
dbSize = this.formatBytes(stats.size);
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
// Active reminders
|
|
||||||
let activeReminders = 0;
|
|
||||||
try {
|
|
||||||
const row = db.prepare(`SELECT COUNT(*) as count FROM reminders WHERE fired = 0`).get() as { count: number };
|
|
||||||
activeReminders = row.count;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
// Total room messages
|
|
||||||
let totalRoomMessages = 0;
|
|
||||||
try {
|
|
||||||
const row = db.prepare(`SELECT SUM(total_messages) as total FROM room_milestones`).get() as { total: number | null };
|
|
||||||
totalRoomMessages = row?.total ?? 0;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
|
|
||||||
// LLM status
|
|
||||||
let llmStatus = "disabled";
|
|
||||||
if (LLM_ENABLED) {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${OLLAMA_HOST}/api/tags`, { signal: AbortSignal.timeout(5000) });
|
|
||||||
llmStatus = res.ok ? `running (${process.env.OLLAMA_MODEL})` : `error (HTTP ${res.status})`;
|
|
||||||
} catch {
|
|
||||||
llmStatus = "error (unreachable)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Estimated LLM tokens (rough: ~1.3 tokens per word for English)
|
|
||||||
let estimatedTokens = "N/A";
|
|
||||||
if (LLM_ENABLED) {
|
|
||||||
try {
|
|
||||||
const row = db.prepare(`SELECT COUNT(*) as classified FROM llm_classifications`).get() as { classified: number };
|
|
||||||
const wordRow = db.prepare(`SELECT SUM(total_words) as words FROM user_stats`).get() as { words: number | null };
|
|
||||||
const totalWords = wordRow?.words ?? 0;
|
|
||||||
const tokens = Math.round(totalWords * 1.3);
|
|
||||||
estimatedTokens = `~${this.formatNumber(tokens)} tokens (~${this.formatNumber(row.classified)} classifications)`;
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
"Bot Info:",
|
|
||||||
"",
|
|
||||||
`Uptime: ${uptime}`,
|
|
||||||
`Messages processed (session): ${this.formatNumber(messagesProcessed)}`,
|
|
||||||
`Total room messages (all time): ${this.formatNumber(totalRoomMessages)}`,
|
|
||||||
`Database size: ${dbSize}`,
|
|
||||||
`Active reminders: ${activeReminders}`,
|
|
||||||
`LLM classifier: ${llmStatus}`,
|
|
||||||
`LLM tokens processed: ${estimatedTokens}`,
|
|
||||||
];
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatUptime(ms: number): string {
|
|
||||||
const s = Math.floor(ms / 1000);
|
|
||||||
const days = Math.floor(s / 86400);
|
|
||||||
const hours = Math.floor((s % 86400) / 3600);
|
|
||||||
const minutes = Math.floor((s % 3600) / 60);
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (days > 0) parts.push(`${days}d`);
|
|
||||||
if (hours > 0) parts.push(`${hours}h`);
|
|
||||||
parts.push(`${minutes}m`);
|
|
||||||
return parts.join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatBytes(bytes: number): string {
|
|
||||||
if (bytes < 1024) return `${bytes} B`;
|
|
||||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
||||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatNumber(n: number): string {
|
|
||||||
return n.toLocaleString("en-US");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,332 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { RateLimitsPlugin } from "./ratelimits";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
interface BandsintownEvent {
|
|
||||||
venue: { name: string; city: string; country: string };
|
|
||||||
datetime: string;
|
|
||||||
offers: any[];
|
|
||||||
lineup: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export class ConcertsPlugin extends Plugin {
|
|
||||||
private rateLimitPlugin: RateLimitsPlugin;
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient, rateLimitPlugin: RateLimitsPlugin) {
|
|
||||||
super(client);
|
|
||||||
this.rateLimitPlugin = rateLimitPlugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "concerts";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
name: "concerts",
|
|
||||||
description: "Search upcoming concerts or manage your artist watchlist",
|
|
||||||
usage: "!concerts <artist> | !concerts watch <artist> | !concerts watching | !concerts unwatch <artist>",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!this.isCommand(ctx.body, "concerts")) return;
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "concerts").trim();
|
|
||||||
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(
|
|
||||||
ctx.roomId,
|
|
||||||
ctx.eventId,
|
|
||||||
"Usage: !concerts <artist> | !concerts watch <artist> | !concerts watching | !concerts unwatch <artist>"
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.toLowerCase().startsWith("watch ")) {
|
|
||||||
const artist = args.slice(6).trim();
|
|
||||||
if (!artist) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Specify an artist to watch.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.handleWatch(ctx, artist);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.toLowerCase() === "watching") {
|
|
||||||
await this.handleWatching(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.toLowerCase().startsWith("unwatch ")) {
|
|
||||||
const artist = args.slice(8).trim();
|
|
||||||
if (!artist) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Specify an artist to unwatch.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.handleUnwatch(ctx, artist);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: search by artist name
|
|
||||||
await this.handleArtistSearch(ctx, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
private getApiKey(): string | undefined {
|
|
||||||
return process.env.BANDSINTOWN_API_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleArtistSearch(ctx: MessageContext, artist: string): Promise<void> {
|
|
||||||
const key = this.getApiKey();
|
|
||||||
if (!key) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Bandsintown API key not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = this.rateLimitPlugin.checkLimit(
|
|
||||||
ctx.sender,
|
|
||||||
"concerts",
|
|
||||||
parseInt(process.env.RATELIMIT_CONCERTS ?? "10")
|
|
||||||
);
|
|
||||||
if (!allowed) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "You've reached your daily concerts lookup quota. Try again tomorrow.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const events = await this.fetchArtistEvents(artist, key);
|
|
||||||
|
|
||||||
if (!events || events.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No upcoming events found for "${artist}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = events.slice(0, 10).map((evt) => {
|
|
||||||
const lineup = evt.lineup?.join(", ") ?? artist;
|
|
||||||
const venue = evt.venue?.name ?? "Unknown Venue";
|
|
||||||
const city = evt.venue?.city ?? "Unknown City";
|
|
||||||
const date = this.formatDate(evt.datetime);
|
|
||||||
return `${lineup} — ${venue}, ${city} — ${date}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
const header = `Upcoming events for "${artist}" (${Math.min(events.length, 10)} of ${events.length}):`;
|
|
||||||
await this.sendMessage(ctx.roomId, `${header}\n${lines.join("\n")}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Concerts artist search failed for "${artist}": ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch concert data. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchArtistEvents(artist: string, apiKey: string): Promise<BandsintownEvent[]> {
|
|
||||||
const cacheKey = artist.toLowerCase();
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Check cache (6 hours = 21600 seconds)
|
|
||||||
const cached = db
|
|
||||||
.prepare(`SELECT data FROM concerts_cache WHERE location_key = ? AND cached_at > unixepoch() - 21600`)
|
|
||||||
.get(cacheKey) as { data: string } | undefined;
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
return JSON.parse(cached.data);
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `https://rest.bandsintown.com/artists/${encodeURIComponent(artist)}/events?app_id=${encodeURIComponent(apiKey)}&date=upcoming`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Bandsintown API returned ${res.status} for artist "${artist}"`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as BandsintownEvent[];
|
|
||||||
|
|
||||||
if (!Array.isArray(data)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cache the result
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO concerts_cache (location_key, data, cached_at) VALUES (?, ?, unixepoch())
|
|
||||||
ON CONFLICT(location_key) DO UPDATE SET data = excluded.data, cached_at = unixepoch()`
|
|
||||||
).run(cacheKey, JSON.stringify(data));
|
|
||||||
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWatch(ctx: MessageContext, artist: string): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
try {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO concert_watchlist (user_id, room_id, artist, created_at) VALUES (?, ?, ?, unixepoch())`
|
|
||||||
).run(ctx.sender, ctx.roomId, artist.toLowerCase());
|
|
||||||
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Now watching "${artist}" for upcoming concerts.`);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === "SQLITE_CONSTRAINT_UNIQUE" || err?.message?.includes("UNIQUE")) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `You're already watching "${artist}".`);
|
|
||||||
} else {
|
|
||||||
logger.error(`Failed to add concert watch for ${ctx.sender}: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to add artist to watchlist.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWatching(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT artist FROM concert_watchlist WHERE user_id = ? AND room_id = ? ORDER BY artist`)
|
|
||||||
.all(ctx.sender, ctx.roomId) as { artist: string }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "You're not watching any artists. Use !concerts watch <artist> to add one.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = rows.map((r) => `- ${r.artist}`).join("\n");
|
|
||||||
await this.sendMessage(ctx.roomId, `Your watched artists:\n${list}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleUnwatch(ctx: MessageContext, artist: string): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const result = db
|
|
||||||
.prepare(`DELETE FROM concert_watchlist WHERE user_id = ? AND room_id = ? AND artist = ?`)
|
|
||||||
.run(ctx.sender, ctx.roomId, artist.toLowerCase());
|
|
||||||
|
|
||||||
if (result.changes > 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Stopped watching "${artist}".`);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `You weren't watching "${artist}".`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by the scheduler to post a weekly concert digest.
|
|
||||||
* Only posts on Sundays (day 0).
|
|
||||||
*/
|
|
||||||
async postWeeklyDigest(botRooms: string[]): Promise<void> {
|
|
||||||
const today = new Date();
|
|
||||||
if (today.getUTCDay() !== 0) return;
|
|
||||||
|
|
||||||
const key = this.getApiKey();
|
|
||||||
if (!key) {
|
|
||||||
logger.warn("BANDSINTOWN_API_KEY not set, skipping concert digest");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Get all unique watched artists across all users
|
|
||||||
const watchedArtists = db
|
|
||||||
.prepare(`SELECT DISTINCT artist FROM concert_watchlist`)
|
|
||||||
.all() as { artist: string }[];
|
|
||||||
|
|
||||||
if (watchedArtists.length === 0) return;
|
|
||||||
|
|
||||||
// Calculate the date range for the next 7 days
|
|
||||||
const now = new Date();
|
|
||||||
const weekFromNow = new Date(now);
|
|
||||||
weekFromNow.setUTCDate(weekFromNow.getUTCDate() + 7);
|
|
||||||
|
|
||||||
const startIso = now.toISOString().slice(0, 10);
|
|
||||||
const endIso = weekFromNow.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// Fetch events for each watched artist
|
|
||||||
const upcomingShows: { artist: string; events: BandsintownEvent[] }[] = [];
|
|
||||||
|
|
||||||
for (const { artist } of watchedArtists) {
|
|
||||||
try {
|
|
||||||
const events = await this.fetchArtistEvents(artist, key);
|
|
||||||
const thisWeek = events.filter((evt) => {
|
|
||||||
const eventDate = evt.datetime?.slice(0, 10);
|
|
||||||
return eventDate && eventDate >= startIso && eventDate <= endIso;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (thisWeek.length > 0) {
|
|
||||||
upcomingShows.push({ artist, events: thisWeek });
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to fetch events for watched artist "${artist}": ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (upcomingShows.length === 0) return;
|
|
||||||
|
|
||||||
// Build summary message for bot rooms
|
|
||||||
const lines: string[] = ["Concert Digest — This Week:", ""];
|
|
||||||
for (const { artist, events } of upcomingShows) {
|
|
||||||
for (const evt of events) {
|
|
||||||
const venue = evt.venue?.name ?? "Unknown Venue";
|
|
||||||
const city = evt.venue?.city ?? "Unknown City";
|
|
||||||
const date = this.formatDate(evt.datetime);
|
|
||||||
const lineup = evt.lineup?.join(", ") ?? artist;
|
|
||||||
lines.push(`${lineup} — ${venue}, ${city} — ${date}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = lines.join("\n");
|
|
||||||
|
|
||||||
for (const roomId of botRooms) {
|
|
||||||
try {
|
|
||||||
await this.sendMessage(roomId, message);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to post concert digest to ${roomId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DM users who are watching artists with shows this week
|
|
||||||
const artistsWithShows = new Set(upcomingShows.map((s) => s.artist));
|
|
||||||
|
|
||||||
const watchers = db
|
|
||||||
.prepare(`SELECT DISTINCT user_id, artist FROM concert_watchlist WHERE artist IN (${[...artistsWithShows].map(() => "?").join(",")})`)
|
|
||||||
.all(...artistsWithShows) as { user_id: string; artist: string }[];
|
|
||||||
|
|
||||||
// Group by user
|
|
||||||
const userArtists = new Map<string, string[]>();
|
|
||||||
for (const { user_id, artist } of watchers) {
|
|
||||||
if (!userArtists.has(user_id)) userArtists.set(user_id, []);
|
|
||||||
userArtists.get(user_id)!.push(artist);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [userId, artists] of userArtists) {
|
|
||||||
const dmLines: string[] = ["Concerts this week for your watched artists:", ""];
|
|
||||||
for (const artist of artists) {
|
|
||||||
const showData = upcomingShows.find((s) => s.artist === artist);
|
|
||||||
if (!showData) continue;
|
|
||||||
for (const evt of showData.events) {
|
|
||||||
const venue = evt.venue?.name ?? "Unknown Venue";
|
|
||||||
const city = evt.venue?.city ?? "Unknown City";
|
|
||||||
const date = this.formatDate(evt.datetime);
|
|
||||||
const lineup = evt.lineup?.join(", ") ?? artist;
|
|
||||||
dmLines.push(`${lineup} — ${venue}, ${city} — ${date}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.sendDm(userId, dmLines.join("\n"));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to DM concert digest to ${userId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatDate(datetime: string): string {
|
|
||||||
try {
|
|
||||||
const d = new Date(datetime);
|
|
||||||
return d.toLocaleDateString("en-US", {
|
|
||||||
weekday: "short",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
year: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return datetime?.slice(0, 10) ?? "Unknown Date";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,277 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const MONTH_NAMES = [
|
|
||||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
||||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
||||||
];
|
|
||||||
|
|
||||||
function formatDate(dateStr: string): string {
|
|
||||||
const d = new Date(dateStr + "T00:00:00Z");
|
|
||||||
const month = MONTH_NAMES[d.getUTCMonth()];
|
|
||||||
const day = d.getUTCDate();
|
|
||||||
const year = d.getUTCFullYear();
|
|
||||||
return `${month} ${day}, ${year}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function todayStr(): string {
|
|
||||||
const now = new Date();
|
|
||||||
const y = now.getUTCFullYear();
|
|
||||||
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
||||||
const d = String(now.getUTCDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${d}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function daysBetween(a: string, b: string): number {
|
|
||||||
const msA = new Date(a + "T00:00:00Z").getTime();
|
|
||||||
const msB = new Date(b + "T00:00:00Z").getTime();
|
|
||||||
return Math.round((msB - msA) / (1000 * 60 * 60 * 24));
|
|
||||||
}
|
|
||||||
|
|
||||||
interface CountdownRow {
|
|
||||||
id: number;
|
|
||||||
user_id: string;
|
|
||||||
room_id: string;
|
|
||||||
label: string;
|
|
||||||
target_date: string;
|
|
||||||
public: number;
|
|
||||||
completed: number;
|
|
||||||
created_at: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class CountdownPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "countdown";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "countdown add", description: "Add a public countdown", usage: '!countdown add "<label>" <YYYY-MM-DD>' },
|
|
||||||
{ name: "countdown private", description: "Add a private countdown", usage: '!countdown private "<label>" <YYYY-MM-DD>' },
|
|
||||||
{ name: "countdown mine", description: "List your own countdowns" },
|
|
||||||
{ name: "countdown remove", description: "Remove a countdown", usage: "!countdown remove <id>" },
|
|
||||||
{ name: "countdown", description: "List countdowns or show a specific one", usage: "!countdown [id]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "countdown add")) {
|
|
||||||
await this.handleAdd(ctx, true);
|
|
||||||
} else if (this.isCommand(ctx.body, "countdown private")) {
|
|
||||||
await this.handleAdd(ctx, false);
|
|
||||||
} else if (this.isCommand(ctx.body, "countdown mine")) {
|
|
||||||
await this.handleMine(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "countdown remove")) {
|
|
||||||
await this.handleRemove(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "countdown")) {
|
|
||||||
const args = this.getArgs(ctx.body, "countdown").trim();
|
|
||||||
if (args && /^\d+$/.test(args)) {
|
|
||||||
await this.handleShow(ctx, parseInt(args));
|
|
||||||
} else if (!args) {
|
|
||||||
await this.handleList(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseAddArgs(raw: string): { label: string; date: string } | null {
|
|
||||||
// Expect: "<label>" <YYYY-MM-DD>
|
|
||||||
const match = raw.match(/^"([^"]+)"\s+(\d{4}-\d{2}-\d{2})$/);
|
|
||||||
if (!match) return null;
|
|
||||||
return { label: match[1], date: match[2] };
|
|
||||||
}
|
|
||||||
|
|
||||||
private isValidDate(dateStr: string): boolean {
|
|
||||||
const d = new Date(dateStr + "T00:00:00Z");
|
|
||||||
return !isNaN(d.getTime());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleAdd(ctx: MessageContext, isPublic: boolean): Promise<void> {
|
|
||||||
const command = isPublic ? "countdown add" : "countdown private";
|
|
||||||
const args = this.getArgs(ctx.body, command).trim();
|
|
||||||
const parsed = this.parseAddArgs(args);
|
|
||||||
|
|
||||||
if (!parsed) {
|
|
||||||
const usage = isPublic
|
|
||||||
? '!countdown add "<label>" <YYYY-MM-DD>'
|
|
||||||
: '!countdown private "<label>" <YYYY-MM-DD>';
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Usage: ${usage}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isValidDate(parsed.date)) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Invalid date format. Use YYYY-MM-DD.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const result = db.prepare(
|
|
||||||
`INSERT INTO countdowns (user_id, room_id, label, target_date, public) VALUES (?, ?, ?, ?, ?)`
|
|
||||||
).run(ctx.sender, ctx.roomId, parsed.label, parsed.date, isPublic ? 1 : 0);
|
|
||||||
|
|
||||||
const id = result.lastInsertRowid;
|
|
||||||
const today = todayStr();
|
|
||||||
const days = daysBetween(today, parsed.date);
|
|
||||||
const direction = days >= 0 ? `${days} days away` : `${Math.abs(days)} days ago`;
|
|
||||||
const visibility = isPublic ? "public" : "private";
|
|
||||||
|
|
||||||
await this.sendReply(
|
|
||||||
ctx.roomId,
|
|
||||||
ctx.eventId,
|
|
||||||
`Countdown #${id} added (${visibility}): "${parsed.label}" on ${formatDate(parsed.date)} — ${direction}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRemove(ctx: MessageContext): Promise<void> {
|
|
||||||
const idStr = this.getArgs(ctx.body, "countdown remove").trim();
|
|
||||||
if (!idStr || !/^\d+$/.test(idStr)) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !countdown remove <id>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = parseInt(idStr);
|
|
||||||
const db = getDb();
|
|
||||||
const result = db.prepare(
|
|
||||||
`DELETE FROM countdowns WHERE id = ? AND user_id = ?`
|
|
||||||
).run(id, ctx.sender);
|
|
||||||
|
|
||||||
if (result.changes > 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Countdown #${id} removed.`);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No countdown found with ID ${id} (or it's not yours).`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleShow(ctx: MessageContext, id: number): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db.prepare(
|
|
||||||
`SELECT * FROM countdowns WHERE id = ? AND room_id = ? AND completed = 0`
|
|
||||||
).get(id, ctx.roomId) as CountdownRow | undefined;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No active countdown found with ID ${id}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only show private countdowns to their owner
|
|
||||||
if (!row.public && row.user_id !== ctx.sender) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No active countdown found with ID ${id}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = todayStr();
|
|
||||||
const days = daysBetween(today, row.target_date);
|
|
||||||
const visibility = row.public ? "public" : "private";
|
|
||||||
|
|
||||||
let detail: string;
|
|
||||||
if (days > 0) {
|
|
||||||
detail = `#${row.id} "${row.label}" — ${days} days (${formatDate(row.target_date)}) [${visibility}]`;
|
|
||||||
} else if (days === 0) {
|
|
||||||
detail = `#${row.id} "${row.label}" — today! (${formatDate(row.target_date)}) [${visibility}]`;
|
|
||||||
} else {
|
|
||||||
detail = `#${row.id} "${row.label}" — happened ${Math.abs(days)} days ago (${formatDate(row.target_date)}) [${visibility}]`;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, detail);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleList(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const today = todayStr();
|
|
||||||
|
|
||||||
// Fetch all public countdowns for this room + sender's private ones, not yet completed
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT * FROM countdowns
|
|
||||||
WHERE room_id = ? AND completed = 0
|
|
||||||
AND (public = 1 OR user_id = ?)
|
|
||||||
ORDER BY target_date ASC`
|
|
||||||
).all(ctx.roomId, ctx.sender) as CountdownRow[];
|
|
||||||
|
|
||||||
// Cleanup: mark completed any that are past > 7 days
|
|
||||||
const toComplete: number[] = [];
|
|
||||||
const visible: CountdownRow[] = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const days = daysBetween(today, row.target_date);
|
|
||||||
if (days < 0 && Math.abs(days) > 7) {
|
|
||||||
toComplete.push(row.id);
|
|
||||||
} else {
|
|
||||||
visible.push(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toComplete.length > 0) {
|
|
||||||
const placeholders = toComplete.map(() => "?").join(",");
|
|
||||||
db.prepare(`UPDATE countdowns SET completed = 1 WHERE id IN (${placeholders})`).run(...toComplete);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visible.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No active countdowns.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = this.formatCountdownLines(visible, today);
|
|
||||||
await this.sendMessage(ctx.roomId, `Community Countdowns\n\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMine(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const today = todayStr();
|
|
||||||
|
|
||||||
const rows = db.prepare(
|
|
||||||
`SELECT * FROM countdowns
|
|
||||||
WHERE user_id = ? AND room_id = ? AND completed = 0
|
|
||||||
ORDER BY target_date ASC`
|
|
||||||
).all(ctx.sender, ctx.roomId) as CountdownRow[];
|
|
||||||
|
|
||||||
// Cleanup: mark completed any that are past > 7 days
|
|
||||||
const toComplete: number[] = [];
|
|
||||||
const visible: CountdownRow[] = [];
|
|
||||||
|
|
||||||
for (const row of rows) {
|
|
||||||
const days = daysBetween(today, row.target_date);
|
|
||||||
if (days < 0 && Math.abs(days) > 7) {
|
|
||||||
toComplete.push(row.id);
|
|
||||||
} else {
|
|
||||||
visible.push(row);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (toComplete.length > 0) {
|
|
||||||
const placeholders = toComplete.map(() => "?").join(",");
|
|
||||||
db.prepare(`UPDATE countdowns SET completed = 1 WHERE id IN (${placeholders})`).run(...toComplete);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visible.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "You have no active countdowns.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = this.formatCountdownLines(visible, today);
|
|
||||||
await this.sendMessage(ctx.roomId, `Your Countdowns\n\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatCountdownLines(rows: CountdownRow[], today: string): string[] {
|
|
||||||
return rows.map((row) => {
|
|
||||||
const days = daysBetween(today, row.target_date);
|
|
||||||
const label = row.label;
|
|
||||||
|
|
||||||
if (days > 0) {
|
|
||||||
const pad = " ".repeat(Math.max(0, 30 - label.length));
|
|
||||||
return `${label}${pad} — ${days} days (${formatDate(row.target_date)})`;
|
|
||||||
} else if (days === 0) {
|
|
||||||
const pad = " ".repeat(Math.max(0, 30 - label.length));
|
|
||||||
return `${label}${pad} — today! (${formatDate(row.target_date)})`;
|
|
||||||
} else {
|
|
||||||
const ago = Math.abs(days);
|
|
||||||
const padLabel = `\u2705 ${label}`;
|
|
||||||
const pad = " ".repeat(Math.max(0, 30 - padLabel.length));
|
|
||||||
return `${padLabel}${pad} — happened ${ago} days ago`;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import { RemindersPlugin } from "./reminders";
|
|
||||||
import { WotdPlugin } from "./wotd";
|
|
||||||
import { HolidaysPlugin } from "./holidays";
|
|
||||||
import { GamingPlugin } from "./gaming";
|
|
||||||
import { BirthdayPlugin } from "./birthday";
|
|
||||||
import { AnimePlugin } from "./anime";
|
|
||||||
import { MoviesPlugin } from "./movies";
|
|
||||||
import { ConcertsPlugin } from "./concerts";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
interface ScheduledJob {
|
|
||||||
job_name: string;
|
|
||||||
hour: number;
|
|
||||||
minute: number;
|
|
||||||
enabled: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class DailyScheduler extends Plugin {
|
|
||||||
private remindersPlugin: RemindersPlugin;
|
|
||||||
private wotdPlugin: WotdPlugin;
|
|
||||||
private holidaysPlugin: HolidaysPlugin;
|
|
||||||
private gamingPlugin: GamingPlugin;
|
|
||||||
private birthdayPlugin: BirthdayPlugin;
|
|
||||||
private animePlugin: AnimePlugin;
|
|
||||||
private moviesPlugin: MoviesPlugin;
|
|
||||||
private concertsPlugin: ConcertsPlugin;
|
|
||||||
private botRooms: string[];
|
|
||||||
private firedToday = new Set<string>();
|
|
||||||
private tickInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
private reminderInterval: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
constructor(
|
|
||||||
client: IMatrixClient,
|
|
||||||
remindersPlugin: RemindersPlugin,
|
|
||||||
wotdPlugin: WotdPlugin,
|
|
||||||
holidaysPlugin: HolidaysPlugin,
|
|
||||||
gamingPlugin: GamingPlugin,
|
|
||||||
birthdayPlugin: BirthdayPlugin,
|
|
||||||
animePlugin: AnimePlugin,
|
|
||||||
moviesPlugin: MoviesPlugin,
|
|
||||||
concertsPlugin: ConcertsPlugin,
|
|
||||||
botRooms: string[]
|
|
||||||
) {
|
|
||||||
super(client);
|
|
||||||
this.remindersPlugin = remindersPlugin;
|
|
||||||
this.wotdPlugin = wotdPlugin;
|
|
||||||
this.holidaysPlugin = holidaysPlugin;
|
|
||||||
this.gamingPlugin = gamingPlugin;
|
|
||||||
this.birthdayPlugin = birthdayPlugin;
|
|
||||||
this.animePlugin = animePlugin;
|
|
||||||
this.moviesPlugin = moviesPlugin;
|
|
||||||
this.concertsPlugin = concertsPlugin;
|
|
||||||
this.botRooms = botRooms;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "daily";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "schedule", description: "Adjust scheduled post time (admin)", usage: "!schedule <job> <HH:MM>", adminOnly: true },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
start(): void {
|
|
||||||
// Check scheduled jobs every 60 seconds
|
|
||||||
this.tickInterval = setInterval(() => this.tick(), 60_000);
|
|
||||||
|
|
||||||
// Check reminders every 30 seconds
|
|
||||||
this.reminderInterval = setInterval(() => {
|
|
||||||
this.remindersPlugin.checkReminders().catch((err) => {
|
|
||||||
logger.error(`Reminder check failed: ${err}`);
|
|
||||||
});
|
|
||||||
}, 30_000);
|
|
||||||
|
|
||||||
logger.info("DailyScheduler started");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run missed jobs. Call this AFTER the Matrix client has fully started
|
|
||||||
* and E2EE is initialized.
|
|
||||||
*/
|
|
||||||
async catchUpMissedJobs(): Promise<void> {
|
|
||||||
// Short delay to ensure crypto is fully ready
|
|
||||||
await new Promise((resolve) => setTimeout(resolve, 5000));
|
|
||||||
await this.catchUp();
|
|
||||||
}
|
|
||||||
|
|
||||||
private async catchUp(): Promise<void> {
|
|
||||||
const now = new Date();
|
|
||||||
const currentHour = now.getUTCHours();
|
|
||||||
const currentMinute = now.getUTCMinutes();
|
|
||||||
const today = now.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const jobs = db.prepare(`SELECT * FROM scheduler_config WHERE enabled = 1`).all() as ScheduledJob[];
|
|
||||||
|
|
||||||
for (const job of jobs) {
|
|
||||||
const scheduledMinutes = job.hour * 60 + job.minute;
|
|
||||||
const currentMinutes = currentHour * 60 + currentMinute;
|
|
||||||
|
|
||||||
// If the scheduled time has already passed today, run it now
|
|
||||||
if (scheduledMinutes <= currentMinutes) {
|
|
||||||
const key = `${job.job_name}:${today}`;
|
|
||||||
logger.info(`Catching up missed job: ${job.job_name}`);
|
|
||||||
try {
|
|
||||||
await this.runJob(job.job_name);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Catch-up for ${job.job_name} failed: ${err}`);
|
|
||||||
}
|
|
||||||
// Only mark as fired if the job actually produced output.
|
|
||||||
// Jobs that use DB logs (holidays, wotd) check for existing entries,
|
|
||||||
// so leaving firedToday unset lets the tick retry on the next minute.
|
|
||||||
if (this.didJobComplete(job.job_name, today)) {
|
|
||||||
this.firedToday.add(key);
|
|
||||||
} else {
|
|
||||||
logger.warn(`Job ${job.job_name} did not produce output — will retry on next tick`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stop(): void {
|
|
||||||
if (this.tickInterval) clearInterval(this.tickInterval);
|
|
||||||
if (this.reminderInterval) clearInterval(this.reminderInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "schedule")) {
|
|
||||||
await this.handleSchedule(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private tick(): void {
|
|
||||||
const now = new Date();
|
|
||||||
const currentHour = now.getUTCHours();
|
|
||||||
const currentMinute = now.getUTCMinutes();
|
|
||||||
const today = now.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const jobs = db.prepare(`SELECT * FROM scheduler_config WHERE enabled = 1`).all() as ScheduledJob[];
|
|
||||||
|
|
||||||
for (const job of jobs) {
|
|
||||||
const key = `${job.job_name}:${today}`;
|
|
||||||
if (this.firedToday.has(key)) continue;
|
|
||||||
// Fire at the scheduled minute, or any minute after if it was missed (e.g. connection blip)
|
|
||||||
const scheduledMinutes = job.hour * 60 + job.minute;
|
|
||||||
const currentMinutes = currentHour * 60 + currentMinute;
|
|
||||||
if (currentMinutes < scheduledMinutes) continue;
|
|
||||||
|
|
||||||
this.runJob(job.job_name).then(() => {
|
|
||||||
if (this.didJobComplete(job.job_name, today)) {
|
|
||||||
this.firedToday.add(key);
|
|
||||||
}
|
|
||||||
}).catch((err) => {
|
|
||||||
logger.error(`Scheduled job ${job.job_name} failed: ${err}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up old entries from firedToday (keep only today's)
|
|
||||||
for (const key of this.firedToday) {
|
|
||||||
if (!key.endsWith(today)) {
|
|
||||||
this.firedToday.delete(key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async runJob(jobName: string): Promise<void> {
|
|
||||||
logger.info(`Running scheduled job: ${jobName}`);
|
|
||||||
|
|
||||||
switch (jobName) {
|
|
||||||
case "prefetch":
|
|
||||||
await this.runPrefetch();
|
|
||||||
break;
|
|
||||||
case "maintenance":
|
|
||||||
this.runMaintenance();
|
|
||||||
break;
|
|
||||||
case "wotd":
|
|
||||||
await this.wotdPlugin.postWotd(this.botRooms);
|
|
||||||
break;
|
|
||||||
case "holidays":
|
|
||||||
await this.holidaysPlugin.postHolidays(this.botRooms);
|
|
||||||
break;
|
|
||||||
case "releases":
|
|
||||||
await this.gamingPlugin.postReleases(this.botRooms);
|
|
||||||
break;
|
|
||||||
case "birthday_check":
|
|
||||||
await this.birthdayPlugin.checkAndPost(this.botRooms);
|
|
||||||
break;
|
|
||||||
case "anime_releases":
|
|
||||||
await this.animePlugin.postDailyReleases(this.botRooms);
|
|
||||||
break;
|
|
||||||
case "movie_releases":
|
|
||||||
await this.moviesPlugin.postDailyReleases(this.botRooms);
|
|
||||||
break;
|
|
||||||
case "concert_digest":
|
|
||||||
await this.concertsPlugin.postWeeklyDigest(this.botRooms);
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
logger.warn(`Unknown scheduled job: ${jobName}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetch API data for holidays and WOTD so the post step is network-free.
|
|
||||||
* Runs early (default 00:05 UTC). Each prefetch is individually error-handled
|
|
||||||
* so one failure doesn't block the others.
|
|
||||||
*/
|
|
||||||
private async runPrefetch(): Promise<void> {
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.holidaysPlugin.prefetch();
|
|
||||||
results.push("holidays");
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Holiday prefetch failed: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
await this.wotdPlugin.prefetch();
|
|
||||||
results.push("wotd");
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`WOTD prefetch failed: ${err}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`Prefetch complete: ${results.join(", ") || "none succeeded"}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Nightly maintenance: prune old data to prevent DB bloat.
|
|
||||||
* Runs after prefetch (default 00:15 UTC).
|
|
||||||
*/
|
|
||||||
private runMaintenance(): void {
|
|
||||||
const db = getDb();
|
|
||||||
let totalDeleted = 0;
|
|
||||||
|
|
||||||
const prune = (label: string, sql: string, ...params: any[]) => {
|
|
||||||
try {
|
|
||||||
const result = db.prepare(sql).run(...params);
|
|
||||||
if (result.changes > 0) {
|
|
||||||
logger.info(`Maintenance: pruned ${result.changes} rows from ${label}`);
|
|
||||||
totalDeleted += result.changes;
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Maintenance: failed to prune ${label}: ${err}`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// llm_classifications — aggregates already in sentiment_stats, potty_mouth, insult_log
|
|
||||||
prune("llm_classifications", `DELETE FROM llm_classifications WHERE classified_at < unixepoch() - ?`, 30 * 86400);
|
|
||||||
|
|
||||||
// xp_log — audit trail, not queried by any command
|
|
||||||
prune("xp_log", `DELETE FROM xp_log WHERE granted_at < datetime('now', '-30 days')`);
|
|
||||||
|
|
||||||
// command_usage — rate limit tracking, old entries useless
|
|
||||||
prune("command_usage", `DELETE FROM command_usage WHERE date < date('now', '-7 days')`);
|
|
||||||
|
|
||||||
// rep_cooldowns — only 24h cooldown, stale entries accumulate
|
|
||||||
prune("rep_cooldowns", `DELETE FROM rep_cooldowns WHERE granted_at < datetime('now', '-2 days')`);
|
|
||||||
|
|
||||||
// daily_prefetch — only today's matters
|
|
||||||
prune("daily_prefetch", `DELETE FROM daily_prefetch WHERE date < date('now', '-3 days')`);
|
|
||||||
|
|
||||||
// Cache tables — stale entries never deleted by app code
|
|
||||||
prune("url_cache", `DELETE FROM url_cache WHERE cached_at < unixepoch() - ?`, 7 * 86400);
|
|
||||||
prune("retro_cache", `DELETE FROM retro_cache WHERE cached_at < unixepoch() - ?`, 14 * 86400);
|
|
||||||
prune("urban_cache", `DELETE FROM urban_cache WHERE cached_at < unixepoch() - ?`, 7 * 86400);
|
|
||||||
prune("stocks_cache", `DELETE FROM stocks_cache WHERE cached_at < unixepoch() - ?`, 1 * 86400);
|
|
||||||
prune("hltb_cache", `DELETE FROM hltb_cache WHERE fetched_at < datetime('now', '-30 days')`);
|
|
||||||
prune("releases_cache", `DELETE FROM releases_cache WHERE fetched_at < datetime('now', '-30 days')`);
|
|
||||||
prune("anime_cache", `DELETE FROM anime_cache WHERE cached_at < unixepoch() - ?`, 30 * 86400);
|
|
||||||
prune("movie_cache", `DELETE FROM movie_cache WHERE cached_at < unixepoch() - ?`, 7 * 86400);
|
|
||||||
prune("concerts_cache", `DELETE FROM concerts_cache WHERE cached_at < unixepoch() - ?`, 14 * 86400);
|
|
||||||
|
|
||||||
// shade_log — if shade feature is disabled, this is dead weight
|
|
||||||
prune("shade_log", `DELETE FROM shade_log WHERE classified_at < datetime('now', '-30 days')`);
|
|
||||||
|
|
||||||
logger.info(`Maintenance complete: ${totalDeleted} total rows pruned`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a job actually produced output by looking at its DB log.
|
|
||||||
* Jobs without a log table are assumed to have completed.
|
|
||||||
*/
|
|
||||||
private didJobComplete(jobName: string, today: string): boolean {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
switch (jobName) {
|
|
||||||
case "prefetch": {
|
|
||||||
// Prefetch is done if at least one of the caches was populated
|
|
||||||
const row = db.prepare(`SELECT job_name FROM daily_prefetch WHERE date = ? LIMIT 1`).get(today);
|
|
||||||
return !!row;
|
|
||||||
}
|
|
||||||
case "holidays": {
|
|
||||||
const row = db.prepare(`SELECT id FROM holidays_log WHERE date = ? LIMIT 1`).get(today);
|
|
||||||
return !!row;
|
|
||||||
}
|
|
||||||
case "wotd": {
|
|
||||||
const row = db.prepare(`SELECT id FROM wotd_log WHERE date = ? LIMIT 1`).get(today);
|
|
||||||
return !!row;
|
|
||||||
}
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSchedule(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!this.isAdmin(ctx.sender)) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "This command is admin-only.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "schedule");
|
|
||||||
const match = args.match(/^(\w+)\s+(\d{1,2}):(\d{2})$/);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !schedule <job> <HH:MM> (UTC)\nJobs: prefetch, maintenance, wotd, holidays, releases, birthday_check, anime_releases, movie_releases, concert_digest");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const [, jobName, hourStr, minuteStr] = match;
|
|
||||||
const hour = parseInt(hourStr);
|
|
||||||
const minute = parseInt(minuteStr);
|
|
||||||
|
|
||||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Invalid time. Use HH:MM in 24-hour UTC format.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const result = db
|
|
||||||
.prepare(`UPDATE scheduler_config SET hour = ?, minute = ? WHERE job_name = ?`)
|
|
||||||
.run(hour, minute, jobName);
|
|
||||||
|
|
||||||
if (result.changes === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Unknown job "${jobName}". Valid jobs: prefetch, maintenance, wotd, holidays, releases, birthday_check, anime_releases, movie_releases, concert_digest`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clear today's fired status so it can re-fire at the new time
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
this.firedToday.delete(`${jobName}:${today}`);
|
|
||||||
|
|
||||||
await this.sendReply(
|
|
||||||
ctx.roomId,
|
|
||||||
ctx.eventId,
|
|
||||||
`Scheduled "${jobName}" updated to ${hour.toString().padStart(2, "0")}:${minute.toString().padStart(2, "0")} UTC.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,480 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import { DateTime, IANAZone } from "luxon";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
// City map (shared with user.ts but duplicated here to avoid coupling)
|
|
||||||
const CITY_TIMEZONE_MAP: Record<string, string> = {
|
|
||||||
"new york": "America/New_York", "nyc": "America/New_York",
|
|
||||||
"los angeles": "America/Los_Angeles", "la": "America/Los_Angeles",
|
|
||||||
"chicago": "America/Chicago", "denver": "America/Denver",
|
|
||||||
"london": "Europe/London", "paris": "Europe/Paris",
|
|
||||||
"berlin": "Europe/Berlin", "tokyo": "Asia/Tokyo",
|
|
||||||
"sydney": "Australia/Sydney", "toronto": "America/Toronto",
|
|
||||||
"vancouver": "America/Vancouver", "mumbai": "Asia/Kolkata",
|
|
||||||
"dubai": "Asia/Dubai", "singapore": "Asia/Singapore",
|
|
||||||
"hong kong": "Asia/Hong_Kong", "seoul": "Asia/Seoul",
|
|
||||||
"beijing": "Asia/Shanghai", "moscow": "Europe/Moscow",
|
|
||||||
"istanbul": "Europe/Istanbul", "cairo": "Africa/Cairo",
|
|
||||||
"sao paulo": "America/Sao_Paulo", "mexico city": "America/Mexico_City",
|
|
||||||
"amsterdam": "Europe/Amsterdam", "rome": "Europe/Rome",
|
|
||||||
"madrid": "Europe/Madrid", "bangkok": "Asia/Bangkok",
|
|
||||||
"honolulu": "Pacific/Honolulu", "hawaii": "Pacific/Honolulu",
|
|
||||||
"lisbon": "Europe/Lisbon", "dublin": "Europe/Dublin",
|
|
||||||
"athens": "Europe/Athens", "helsinki": "Europe/Helsinki",
|
|
||||||
"warsaw": "Europe/Warsaw", "prague": "Europe/Prague",
|
|
||||||
"vienna": "Europe/Vienna", "zurich": "Europe/Zurich",
|
|
||||||
"brussels": "Europe/Brussels", "oslo": "Europe/Oslo",
|
|
||||||
"stockholm": "Europe/Stockholm", "copenhagen": "Europe/Copenhagen",
|
|
||||||
"bucharest": "Europe/Bucharest", "budapest": "Europe/Budapest",
|
|
||||||
"jakarta": "Asia/Jakarta", "manila": "Asia/Manila",
|
|
||||||
"taipei": "Asia/Taipei", "shanghai": "Asia/Shanghai",
|
|
||||||
"kolkata": "Asia/Kolkata", "delhi": "Asia/Kolkata",
|
|
||||||
"karachi": "Asia/Karachi", "dhaka": "Asia/Dhaka",
|
|
||||||
"riyadh": "Asia/Riyadh", "tehran": "Asia/Tehran",
|
|
||||||
"johannesburg": "Africa/Johannesburg", "nairobi": "Africa/Nairobi",
|
|
||||||
"lagos": "Africa/Lagos", "casablanca": "Africa/Casablanca",
|
|
||||||
"lima": "America/Lima", "bogota": "America/Bogota",
|
|
||||||
"buenos aires": "America/Argentina/Buenos_Aires",
|
|
||||||
"santiago": "America/Santiago", "anchorage": "America/Anchorage",
|
|
||||||
"auckland": "Pacific/Auckland",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Try to resolve a city name to an IANA timezone by checking Region/City patterns. */
|
|
||||||
function guessIANAZone(city: string): string | null {
|
|
||||||
// Capitalize words and replace spaces with underscores to match IANA format
|
|
||||||
const formatted = city
|
|
||||||
.split(/[\s-]+/)
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
||||||
.join("_");
|
|
||||||
|
|
||||||
const regions = ["Europe", "America", "Asia", "Africa", "Pacific", "Australia", "Atlantic", "Indian", "Arctic"];
|
|
||||||
for (const region of regions) {
|
|
||||||
const candidate = `${region}/${formatted}`;
|
|
||||||
if (IANAZone.isValidZone(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const EIGHT_BALL_RESPONSES = [
|
|
||||||
"It is certain.", "It is decidedly so.", "Without a doubt.",
|
|
||||||
"Yes, definitely.", "You may rely on it.", "As I see it, yes.",
|
|
||||||
"Most likely.", "Outlook good.", "Yes.", "Signs point to yes.",
|
|
||||||
"Reply hazy, try again.", "Ask again later.", "Better not tell you now.",
|
|
||||||
"Cannot predict now.", "Concentrate and ask again.",
|
|
||||||
"Don't count on it.", "My reply is no.", "My sources say no.",
|
|
||||||
"Outlook not so good.", "Very doubtful.",
|
|
||||||
];
|
|
||||||
|
|
||||||
const TWINBEE_FACTS = [
|
|
||||||
"TwinBee first appeared in 1985 as a Konami arcade game.",
|
|
||||||
"TwinBee is a cute 'em up (kawaii shooter) — one of the first of its kind.",
|
|
||||||
"The bell power-up system is TwinBee's signature mechanic — juggle bells to change their color!",
|
|
||||||
"Yellow bells give points, blue bells increase speed, white bells give twin shots, green bells give a shield.",
|
|
||||||
"TwinBee's partner is WinBee, a pink ship piloted by Light's girlfriend Pastel.",
|
|
||||||
"The TwinBee series inspired the Parodius games, Konami's parody shooter line.",
|
|
||||||
"Detana!! TwinBee (1991) is considered the series' masterpiece.",
|
|
||||||
"TwinBee Rainbow Bell Adventure turned the shooter into a platformer.",
|
|
||||||
"TwinBee had an anime series called 'TwinBee Paradise' in the 90s.",
|
|
||||||
"In Pop'n TwinBee, you could punch enemies by getting close — a mechanic unique to the SNES version.",
|
|
||||||
"GwinBee was TwinBee's baby sibling, first appearing in Stinger (1987).",
|
|
||||||
"TwinBee's pilot is named Light, and he's the grandson of Dr. Cinnamon.",
|
|
||||||
"The TwinBee series has over 15 entries, spanning arcade, console, and handheld.",
|
|
||||||
"Parodius (from Parody + Gradius) used TwinBee as a playable character alongside Vic Viper.",
|
|
||||||
];
|
|
||||||
|
|
||||||
export class FunPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "fun";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "roll", description: "Dice roller", usage: "!roll <NdN+M>" },
|
|
||||||
{ name: "8ball", description: "Magic 8-ball", usage: "!8ball <question>" },
|
|
||||||
{ name: "coin", description: "Coin flip" },
|
|
||||||
{ name: "time", description: "World clock", usage: "!time <city|@user>..." },
|
|
||||||
{ name: "hltb", description: "HowLongToBeat lookup", usage: "!hltb <game>" },
|
|
||||||
{ name: "twinbee", description: "Random TwinBee lore" },
|
|
||||||
{ name: "poll", description: "Reaction poll", usage: '!poll "Q" "A" "B" ...' },
|
|
||||||
{ name: "weather", description: "Current weather", usage: "!weather <city|zip[,CC]|@user>" },
|
|
||||||
{ name: "dadjoke", description: "Random dad joke" },
|
|
||||||
{ name: "randomwiki", description: "Random Wikipedia article" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "8ball")) {
|
|
||||||
await this.handle8ball(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "roll")) {
|
|
||||||
await this.handleRoll(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "coin")) {
|
|
||||||
await this.handleCoin(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "time")) {
|
|
||||||
await this.handleTime(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "hltb")) {
|
|
||||||
await this.handleHltb(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "twinbee")) {
|
|
||||||
await this.handleTwinbee(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "weather")) {
|
|
||||||
await this.handleWeather(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "poll")) {
|
|
||||||
await this.handlePoll(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "dadjoke")) {
|
|
||||||
await this.handleDadJoke(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "randomwiki")) {
|
|
||||||
await this.handleRandomWiki(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRoll(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "roll") || "1d6";
|
|
||||||
const match = args.match(/^(\d+)?d(\d+)([+-]\d+)?$/i);
|
|
||||||
|
|
||||||
if (!match) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !roll [N]d<sides>[+/-modifier]\nExamples: !roll d20, !roll 2d6+3");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const count = Math.min(parseInt(match[1] || "1"), 100);
|
|
||||||
const sides = Math.min(parseInt(match[2]), 1000);
|
|
||||||
const modifier = parseInt(match[3] || "0");
|
|
||||||
|
|
||||||
if (sides < 1) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Dice need at least 1 side!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const rolls: number[] = [];
|
|
||||||
for (let i = 0; i < count; i++) {
|
|
||||||
rolls.push(Math.floor(Math.random() * sides) + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sum = rolls.reduce((a, b) => a + b, 0) + modifier;
|
|
||||||
const rollStr = count > 1 ? ` (${rolls.join(", ")})` : "";
|
|
||||||
const modStr = modifier !== 0 ? ` ${modifier > 0 ? "+" : ""}${modifier}` : "";
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, `${ctx.sender} rolled ${count}d${sides}${modStr}: ${sum}${rollStr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handle8ball(ctx: MessageContext): Promise<void> {
|
|
||||||
const response = EIGHT_BALL_RESPONSES[Math.floor(Math.random() * EIGHT_BALL_RESPONSES.length)];
|
|
||||||
await this.sendMessage(ctx.roomId, `The Magic 8-Ball says: ${response}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCoin(ctx: MessageContext): Promise<void> {
|
|
||||||
const result = Math.random() < 0.5 ? "Heads" : "Tails";
|
|
||||||
await this.sendMessage(ctx.roomId, `${ctx.sender} flipped a coin: ${result}!`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTime(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "time");
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !time <city|@user> [city2] ...");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Split by comma or multiple words that could be cities/@users
|
|
||||||
const queries = args.split(/,\s*/).map((s) => s.trim()).filter(Boolean);
|
|
||||||
const results: string[] = [];
|
|
||||||
|
|
||||||
for (const query of queries) {
|
|
||||||
if (query.startsWith("@")) {
|
|
||||||
// Look up user timezone
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(query, ctx.roomId) as { timezone: string | null } | undefined;
|
|
||||||
|
|
||||||
if (row?.timezone) {
|
|
||||||
const now = DateTime.now().setZone(row.timezone);
|
|
||||||
results.push(`${query}: ${now.toFormat("HH:mm (ZZZZZ)")} [${row.timezone}]`);
|
|
||||||
} else {
|
|
||||||
results.push(`${query}: timezone not set`);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
const lower = query.toLowerCase();
|
|
||||||
const tz = CITY_TIMEZONE_MAP[lower] || (IANAZone.isValidZone(query) ? query : null) || guessIANAZone(lower);
|
|
||||||
|
|
||||||
if (tz) {
|
|
||||||
const now = DateTime.now().setZone(tz);
|
|
||||||
results.push(`${query}: ${now.toFormat("HH:mm (ZZZZZ)")} [${tz}]`);
|
|
||||||
} else {
|
|
||||||
results.push(`${query}: unknown timezone`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, results.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleHltb(ctx: MessageContext): Promise<void> {
|
|
||||||
const game = this.getArgs(ctx.body, "hltb");
|
|
||||||
if (!game) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !hltb <game name>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Check cache (24h TTL)
|
|
||||||
const cached = db
|
|
||||||
.prepare(`SELECT * FROM hltb_cache WHERE search_term = ? AND fetched_at > datetime('now', '-24 hours')`)
|
|
||||||
.get(game.toLowerCase()) as any;
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
await this.sendHltbResult(ctx.roomId, cached);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { HowLongToBeatService } = await import("howlongtobeat");
|
|
||||||
const service = new HowLongToBeatService();
|
|
||||||
const results = await service.search(game);
|
|
||||||
|
|
||||||
if (!results || results.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No HLTB results found for "${game}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const best = results[0];
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO hltb_cache (game_name, search_term, main_story, main_extra, completionist, data_json)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
best.name,
|
|
||||||
game.toLowerCase(),
|
|
||||||
best.gameplayMain ?? null,
|
|
||||||
best.gameplayMainExtra ?? null,
|
|
||||||
best.gameplayCompletionist ?? null,
|
|
||||||
JSON.stringify(best)
|
|
||||||
);
|
|
||||||
|
|
||||||
await this.sendHltbResult(ctx.roomId, {
|
|
||||||
game_name: best.name,
|
|
||||||
main_story: best.gameplayMain,
|
|
||||||
main_extra: best.gameplayMainExtra,
|
|
||||||
completionist: best.gameplayCompletionist,
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`HLTB lookup failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "HLTB lookup failed. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendHltbResult(roomId: string, data: { game_name: string; main_story: number | null; main_extra: number | null; completionist: number | null }): Promise<void> {
|
|
||||||
const formatHours = (h: number | null) => (h ? `${h} hours` : "N/A");
|
|
||||||
await this.sendMessage(
|
|
||||||
roomId,
|
|
||||||
`${data.game_name}:\n Main Story: ${formatHours(data.main_story)}\n Main + Extra: ${formatHours(data.main_extra)}\n Completionist: ${formatHours(data.completionist)}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTwinbee(ctx: MessageContext): Promise<void> {
|
|
||||||
const fact = TWINBEE_FACTS[Math.floor(Math.random() * TWINBEE_FACTS.length)];
|
|
||||||
await this.sendMessage(ctx.roomId, fact);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWeather(ctx: MessageContext): Promise<void> {
|
|
||||||
const apiKey = process.env.OPENWEATHER_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "OpenWeather API key not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "weather");
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !weather <city> or !weather @user");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let query = args;
|
|
||||||
|
|
||||||
// If it's a user mention, look up their timezone and derive a city
|
|
||||||
if (args.startsWith("@")) {
|
|
||||||
const userId = args.split(/\s/)[0];
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, ctx.roomId) as { timezone: string | null } | undefined;
|
|
||||||
|
|
||||||
if (!row?.timezone) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `${userId} hasn't set a timezone. Use !weather <city> instead.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to extract a city name from the IANA zone (e.g., "America/New_York" -> "New York")
|
|
||||||
const parts = row.timezone.split("/");
|
|
||||||
query = parts[parts.length - 1].replace(/_/g, " ");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Resolve location via geocoding first for accurate results
|
|
||||||
const location = await this.resolveWeatherLocation(query, apiKey);
|
|
||||||
if (!location) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Location "${query}" not found.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const url = `https://api.openweathermap.org/data/2.5/weather?lat=${location.lat}&lon=${location.lon}&appid=${encodeURIComponent(apiKey)}&units=metric`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Weather lookup failed. Try again later.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const temp = data.main.temp;
|
|
||||||
const feelsLike = data.main.feels_like;
|
|
||||||
const tempF = (temp * 9) / 5 + 32;
|
|
||||||
const feelsLikeF = (feelsLike * 9) / 5 + 32;
|
|
||||||
const humidity = data.main.humidity;
|
|
||||||
const description = data.weather?.[0]?.description ?? "unknown";
|
|
||||||
const windSpeed = data.wind?.speed ?? 0;
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
`Weather for ${location.displayName}:`,
|
|
||||||
` ${description.charAt(0).toUpperCase() + description.slice(1)}`,
|
|
||||||
` Temp: ${temp.toFixed(1)}°C / ${tempF.toFixed(1)}°F (feels like ${feelsLike.toFixed(1)}°C / ${feelsLikeF.toFixed(1)}°F)`,
|
|
||||||
` Humidity: ${humidity}% | Wind: ${windSpeed} m/s`,
|
|
||||||
];
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Weather lookup failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Weather lookup failed. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a user query to coordinates using OpenWeather Geocoding API.
|
|
||||||
* Handles zip codes (with optional country: "90210" or "90210,US"),
|
|
||||||
* and disambiguates city names by preferring US matches first, then
|
|
||||||
* returning the most relevant result with state/country info.
|
|
||||||
*/
|
|
||||||
private async resolveWeatherLocation(
|
|
||||||
query: string,
|
|
||||||
apiKey: string
|
|
||||||
): Promise<{ lat: number; lon: number; displayName: string } | null> {
|
|
||||||
const trimmed = query.trim();
|
|
||||||
|
|
||||||
// Check if input looks like a zip/postal code (digits, optionally with country suffix)
|
|
||||||
const zipMatch = trimmed.match(/^(\d{3,10})(?:\s*,\s*([A-Za-z]{2}))?$/);
|
|
||||||
if (zipMatch) {
|
|
||||||
const zip = zipMatch[1];
|
|
||||||
const country = zipMatch[2]?.toUpperCase() ?? "US";
|
|
||||||
const url = `https://api.openweathermap.org/geo/1.0/zip?zip=${encodeURIComponent(zip)},${country}&appid=${encodeURIComponent(apiKey)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (res.ok) {
|
|
||||||
const data = await res.json() as any;
|
|
||||||
if (data.lat != null && data.lon != null) {
|
|
||||||
return {
|
|
||||||
lat: data.lat,
|
|
||||||
lon: data.lon,
|
|
||||||
displayName: `${data.name ?? zip}, ${data.country ?? country}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// If US zip failed and no country was specified, don't fall through to city search for pure numbers
|
|
||||||
if (!zipMatch[2]) return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// City name lookup via geocoding API (returns up to 5 results)
|
|
||||||
const geoUrl = `https://api.openweathermap.org/geo/1.0/direct?q=${encodeURIComponent(trimmed)}&limit=5&appid=${encodeURIComponent(apiKey)}`;
|
|
||||||
const geoRes = await fetch(geoUrl);
|
|
||||||
if (!geoRes.ok) return null;
|
|
||||||
|
|
||||||
const results = await geoRes.json() as any[];
|
|
||||||
if (!results || results.length === 0) return null;
|
|
||||||
|
|
||||||
// If there's only one result, use it directly
|
|
||||||
// If multiple, prefer a US match for ambiguous names, otherwise take the first
|
|
||||||
let best = results[0];
|
|
||||||
if (results.length > 1) {
|
|
||||||
const usMatch = results.find((r: any) => r.country === "US");
|
|
||||||
if (usMatch) best = usMatch;
|
|
||||||
}
|
|
||||||
|
|
||||||
const state = best.state ? `, ${best.state}` : "";
|
|
||||||
const country = best.country ? `, ${best.country}` : "";
|
|
||||||
return {
|
|
||||||
lat: best.lat,
|
|
||||||
lon: best.lon,
|
|
||||||
displayName: `${best.name}${state}${country}`,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handlePoll(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "poll");
|
|
||||||
// Parse quoted strings: !poll "Question" "Option A" "Option B" ...
|
|
||||||
const matches = args.match(/"([^"]+)"/g);
|
|
||||||
|
|
||||||
if (!matches || matches.length < 3) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, 'Usage: !poll "Question?" "Option A" "Option B" ...');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const question = matches[0].replace(/"/g, "");
|
|
||||||
const options = matches.slice(1).map((m) => m.replace(/"/g, ""));
|
|
||||||
|
|
||||||
if (options.length > 10) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Maximum 10 options allowed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const numberEmojis = ["1\uFE0F\u20E3", "2\uFE0F\u20E3", "3\uFE0F\u20E3", "4\uFE0F\u20E3", "5\uFE0F\u20E3", "6\uFE0F\u20E3", "7\uFE0F\u20E3", "8\uFE0F\u20E3", "9\uFE0F\u20E3", "\uD83D\uDD1F"];
|
|
||||||
|
|
||||||
const lines = [`Poll: ${question}`];
|
|
||||||
options.forEach((opt, i) => {
|
|
||||||
lines.push(`${numberEmojis[i]} ${opt}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
const pollEventId = await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
|
|
||||||
// Auto-react with number emojis (fire-and-forget with retry)
|
|
||||||
for (let i = 0; i < options.length; i++) {
|
|
||||||
this.sendReact(ctx.roomId, pollEventId, numberEmojis[i]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleDadJoke(ctx: MessageContext): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await fetch("https://icanhazdadjoke.com/", {
|
|
||||||
headers: { Accept: "application/json" },
|
|
||||||
});
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't fetch a dad joke right now.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as { joke: string };
|
|
||||||
await this.sendMessage(ctx.roomId, data.joke);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Dad joke fetch failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Dad joke machine broke.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRandomWiki(ctx: MessageContext): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await fetch("https://en.wikipedia.org/api/rest_v1/page/random/summary");
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't fetch a random article.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = (await res.json()) as any;
|
|
||||||
const title = data.title ?? "Unknown";
|
|
||||||
let extract = data.extract ?? "";
|
|
||||||
if (extract.length > 500) extract = extract.slice(0, 500) + "...";
|
|
||||||
const pageUrl = data.content_urls?.desktop?.page ?? "";
|
|
||||||
await this.sendMessage(ctx.roomId, `${title}\n\n${extract}\n\nRead more: ${pageUrl}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Random wiki fetch failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch a random article.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
export class GamingPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "gaming";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "releases", description: "Upcoming game releases", usage: "!releases [week|month|search <game>]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "releases")) {
|
|
||||||
await this.handleReleases(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleReleases(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "releases").trim();
|
|
||||||
|
|
||||||
if (args.startsWith("search ")) {
|
|
||||||
const query = args.slice(7).trim();
|
|
||||||
await this.handleSearch(ctx, query);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.RAWG_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API key not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date();
|
|
||||||
const startDate = today.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
let endDate: string;
|
|
||||||
let label: string;
|
|
||||||
if (args === "month") {
|
|
||||||
const end = new Date(today);
|
|
||||||
end.setDate(end.getDate() + 30);
|
|
||||||
endDate = end.toISOString().slice(0, 10);
|
|
||||||
label = "This Month";
|
|
||||||
} else if (args === "week") {
|
|
||||||
const end = new Date(today);
|
|
||||||
end.setDate(end.getDate() + 7);
|
|
||||||
endDate = end.toISOString().slice(0, 10);
|
|
||||||
label = "This Week";
|
|
||||||
} else {
|
|
||||||
// Default: today
|
|
||||||
endDate = startDate;
|
|
||||||
label = "Today";
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `https://api.rawg.io/api/games?key=${encodeURIComponent(apiKey)}&dates=${startDate},${endDate}&ordering=-rating&page_size=10`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch releases from RAWG.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const games = data.results ?? [];
|
|
||||||
|
|
||||||
if (games.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No releases found for ${label.toLowerCase()}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [`Game Releases — ${label}:`];
|
|
||||||
for (const game of games) {
|
|
||||||
const platforms = (game.platforms ?? []).map((p: any) => p.platform?.name).filter(Boolean).join(", ");
|
|
||||||
const hltbInfo = this.getHltbInfo(game.name);
|
|
||||||
let line = `- ${game.name} (${game.released ?? "TBA"})`;
|
|
||||||
if (platforms) line += ` [${platforms}]`;
|
|
||||||
if (hltbInfo) line += ` | ${hltbInfo}`;
|
|
||||||
lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Releases fetch failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch releases. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSearch(ctx: MessageContext, query: string): Promise<void> {
|
|
||||||
if (!query) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !releases search <game name>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = process.env.RAWG_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API key not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `https://api.rawg.io/api/games?key=${encodeURIComponent(apiKey)}&search=${encodeURIComponent(query)}&page_size=5`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG search failed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const games = data.results ?? [];
|
|
||||||
|
|
||||||
if (games.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No games found for "${query}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [`Search results for "${query}":`];
|
|
||||||
for (const game of games) {
|
|
||||||
const platforms = (game.platforms ?? []).map((p: any) => p.platform?.name).filter(Boolean).join(", ");
|
|
||||||
let line = `- ${game.name} (${game.released ?? "TBA"})`;
|
|
||||||
if (platforms) line += ` [${platforms}]`;
|
|
||||||
if (game.rating) line += ` Rating: ${game.rating}/5`;
|
|
||||||
lines.push(line);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`RAWG search failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Search failed. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getHltbInfo(gameName: string): string | null {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT main_story, main_extra FROM hltb_cache WHERE game_name = ? AND fetched_at > datetime('now', '-24 hours') LIMIT 1`)
|
|
||||||
.get(gameName) as { main_story: number | null; main_extra: number | null } | undefined;
|
|
||||||
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
const parts: string[] = [];
|
|
||||||
if (row.main_story) parts.push(`Main: ${row.main_story}h`);
|
|
||||||
if (row.main_extra) parts.push(`Extra: ${row.main_extra}h`);
|
|
||||||
return parts.length > 0 ? `HLTB ${parts.join(", ")}` : null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by DailyScheduler to post the evening releases summary.
|
|
||||||
*/
|
|
||||||
async postReleases(roomIds: string[]): Promise<void> {
|
|
||||||
const apiKey = process.env.RAWG_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
logger.warn("RAWG_API_KEY not set, skipping releases post");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `https://api.rawg.io/api/games?key=${encodeURIComponent(apiKey)}&dates=${today},${today}&ordering=-rating&page_size=10`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`RAWG API returned ${res.status}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const games = data.results ?? [];
|
|
||||||
|
|
||||||
if (games.length === 0) return;
|
|
||||||
|
|
||||||
const lines = ["Today's Game Releases:"];
|
|
||||||
for (const game of games) {
|
|
||||||
const platforms = (game.platforms ?? []).map((p: any) => p.platform?.name).filter(Boolean).join(", ");
|
|
||||||
const hltbInfo = this.getHltbInfo(game.name);
|
|
||||||
let line = `- ${game.name}`;
|
|
||||||
if (platforms) line += ` [${platforms}]`;
|
|
||||||
if (hltbInfo) line += ` | ${hltbInfo}`;
|
|
||||||
lines.push(line);
|
|
||||||
|
|
||||||
// Cache the release data
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO releases_cache (game_name, game_slug, release_date, platforms, genres, rating, data_json)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
game.name,
|
|
||||||
game.slug ?? null,
|
|
||||||
game.released ?? null,
|
|
||||||
platforms || null,
|
|
||||||
(game.genres ?? []).map((g: any) => g.name).join(", ") || null,
|
|
||||||
game.rating ?? null,
|
|
||||||
JSON.stringify(game)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = lines.join("\n");
|
|
||||||
|
|
||||||
for (const roomId of roomIds) {
|
|
||||||
try {
|
|
||||||
await this.sendMessage(roomId, message);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to post releases to ${roomId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check watchlist notifications
|
|
||||||
await this.checkWatchlistNotifications(games);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Releases daily post failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async checkWatchlistNotifications(games: any[]): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
for (const game of games) {
|
|
||||||
const watchers = db
|
|
||||||
.prepare(`SELECT user_id, room_id FROM release_watchlist WHERE (game_name = ? OR game_slug = ?) AND notified_day_of = 0`)
|
|
||||||
.all(game.name, game.slug ?? "") as { user_id: string; room_id: string }[];
|
|
||||||
|
|
||||||
for (const watcher of watchers) {
|
|
||||||
await this.sendDm(watcher.user_id, `${game.name} releases today!`);
|
|
||||||
db.prepare(`UPDATE release_watchlist SET notified_day_of = 1 WHERE user_id = ? AND room_id = ? AND game_name = ?`).run(
|
|
||||||
watcher.user_id,
|
|
||||||
watcher.room_id,
|
|
||||||
game.name
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,534 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
// Countries/sources considered "Western" for featured selection
|
|
||||||
const WESTERN_COUNTRIES = new Set(["US", "GB", "CA", "AU", "NZ", "IE"]);
|
|
||||||
|
|
||||||
const ASIAN_COUNTRIES: Record<string, string> = {
|
|
||||||
JP: "Japan",
|
|
||||||
CN: "China",
|
|
||||||
KR: "South Korea",
|
|
||||||
IN: "India",
|
|
||||||
TH: "Thailand",
|
|
||||||
VN: "Vietnam",
|
|
||||||
TW: "Taiwan",
|
|
||||||
PH: "Philippines",
|
|
||||||
};
|
|
||||||
|
|
||||||
function getAsianCountryCodes(): string[] {
|
|
||||||
const override = process.env.ASIAN_HOLIDAY_COUNTRIES;
|
|
||||||
if (override) return override.split(",").map((s) => s.trim().toUpperCase()).filter(Boolean);
|
|
||||||
return Object.keys(ASIAN_COUNTRIES);
|
|
||||||
}
|
|
||||||
|
|
||||||
function countryLabel(code: string): string {
|
|
||||||
return ASIAN_COUNTRIES[code] ?? code;
|
|
||||||
}
|
|
||||||
|
|
||||||
const RANGE_CACHE_TTL_MS = 60 * 60 * 1000; // 1 hour
|
|
||||||
|
|
||||||
interface RangeCacheEntry {
|
|
||||||
results: { date: string; holiday: Holiday }[];
|
|
||||||
fetchedAt: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface Holiday {
|
|
||||||
name: string;
|
|
||||||
description?: string;
|
|
||||||
country?: string;
|
|
||||||
type?: string;
|
|
||||||
source: "calendarific" | "hebcal" | "aladhan";
|
|
||||||
}
|
|
||||||
|
|
||||||
export class HolidaysPlugin extends Plugin {
|
|
||||||
private rangeCache = new Map<string, RangeCacheEntry>();
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "holidays";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [{ name: "holidays", description: "Holidays and observances", usage: "!holidays [week|month]" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "holidays")) {
|
|
||||||
await this.handleHolidays(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleHolidays(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "holidays").trim().toLowerCase();
|
|
||||||
|
|
||||||
if (args === "week" || args === "month") {
|
|
||||||
await this.handleHolidaysRange(ctx, args);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Default: today — try DB cache first, fall back to live fetch
|
|
||||||
const db = getDb();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
let holidays: Holiday[];
|
|
||||||
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT holidays_json FROM holidays_log WHERE room_id = ? AND date = ?`)
|
|
||||||
.get(ctx.roomId, today) as { holidays_json: string } | undefined;
|
|
||||||
|
|
||||||
if (row) {
|
|
||||||
holidays = JSON.parse(row.holidays_json);
|
|
||||||
} else {
|
|
||||||
// Live fetch from all sources
|
|
||||||
holidays = await this.fetchAllForDate(today);
|
|
||||||
|
|
||||||
// Cache the result so subsequent calls don't re-fetch
|
|
||||||
if (holidays.length > 0) {
|
|
||||||
db.prepare(`INSERT OR IGNORE INTO holidays_log (room_id, date, holidays_json) VALUES (?, ?, ?)`)
|
|
||||||
.run(ctx.roomId, today, JSON.stringify(holidays));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (holidays.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No holidays found for today.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const asianCodes = new Set(Object.keys(ASIAN_COUNTRIES));
|
|
||||||
const lines = holidays.map((h) => {
|
|
||||||
let src = "";
|
|
||||||
if (h.source === "hebcal") src = " (Jewish)";
|
|
||||||
else if (h.source === "aladhan") src = " (Islamic)";
|
|
||||||
else if (h.country && asianCodes.has(h.country)) src = ` (${countryLabel(h.country)})`;
|
|
||||||
return `- ${h.name}${src}${h.description ? `: ${h.description}` : ""}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, `Today's holidays & observances:\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleHolidaysRange(ctx: MessageContext, range: "week" | "month"): Promise<void> {
|
|
||||||
const today = new Date();
|
|
||||||
const startDate = today.toISOString().slice(0, 10);
|
|
||||||
const endDate = new Date(today);
|
|
||||||
endDate.setDate(endDate.getDate() + (range === "week" ? 7 : 30));
|
|
||||||
const endDateStr = endDate.toISOString().slice(0, 10);
|
|
||||||
const label = range === "week" ? "This Week" : "This Month";
|
|
||||||
|
|
||||||
const cacheKey = `${startDate}:${endDateStr}`;
|
|
||||||
const cached = this.rangeCache.get(cacheKey);
|
|
||||||
let allResults: { date: string; holiday: Holiday }[];
|
|
||||||
|
|
||||||
if (cached && Date.now() - cached.fetchedAt < RANGE_CACHE_TTL_MS) {
|
|
||||||
allResults = cached.results;
|
|
||||||
} else {
|
|
||||||
// Fetch from Calendarific (US + Asian countries) and HebCal in parallel
|
|
||||||
const asianCodes = getAsianCountryCodes();
|
|
||||||
const [calendarificHolidays, hebcalHolidays, ...asianRangeResults] = await Promise.all([
|
|
||||||
this.fetchCalendarificRange(startDate, endDateStr),
|
|
||||||
this.fetchHebcalRange(startDate, endDateStr),
|
|
||||||
...asianCodes.map((code) => this.fetchCalendarificRange(startDate, endDateStr, code)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
allResults = [...calendarificHolidays, ...hebcalHolidays, ...asianRangeResults.flat()];
|
|
||||||
this.rangeCache.set(cacheKey, { results: allResults, fetchedAt: Date.now() });
|
|
||||||
}
|
|
||||||
|
|
||||||
const asianCodesSet = new Set(Object.keys(ASIAN_COUNTRIES));
|
|
||||||
const byDate = new Map<string, Holiday[]>();
|
|
||||||
for (const { date, holiday } of allResults) {
|
|
||||||
if (date < startDate || date > endDateStr) continue;
|
|
||||||
if (!byDate.has(date)) byDate.set(date, []);
|
|
||||||
byDate.get(date)!.push(holiday);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortedDates = [...byDate.keys()].sort();
|
|
||||||
if (sortedDates.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No holidays found for ${label.toLowerCase()}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines: string[] = [`Holidays & Observances — ${label}:`];
|
|
||||||
for (const date of sortedDates) {
|
|
||||||
const dateLabel = new Date(date + "T00:00:00Z").toLocaleDateString("en-US", {
|
|
||||||
weekday: "short",
|
|
||||||
month: "short",
|
|
||||||
day: "numeric",
|
|
||||||
timeZone: "UTC",
|
|
||||||
});
|
|
||||||
const holidays = byDate.get(date)!;
|
|
||||||
lines.push(`\n${dateLabel}:`);
|
|
||||||
for (const h of holidays) {
|
|
||||||
let src = "";
|
|
||||||
if (h.source === "hebcal") src = " (Jewish)";
|
|
||||||
else if (h.source === "aladhan") src = " (Islamic)";
|
|
||||||
else if (h.country && asianCodesSet.has(h.country)) src = ` (${countryLabel(h.country)})`;
|
|
||||||
lines.push(` - ${h.name}${src}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Truncate if too long for a single message
|
|
||||||
const message = lines.join("\n");
|
|
||||||
if (message.length > 4000) {
|
|
||||||
await this.sendMessage(ctx.roomId, message.slice(0, 3950) + "\n\n... (truncated)");
|
|
||||||
} else {
|
|
||||||
await this.sendMessage(ctx.roomId, message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchCalendarificRange(startDate: string, endDate: string, country: string = "US"): Promise<{ date: string; holiday: Holiday }[]> {
|
|
||||||
const apiKey = process.env.CALENDARIFIC_API_KEY;
|
|
||||||
if (!apiKey) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Calendarific supports year+month queries — fetch each month in the range
|
|
||||||
const start = new Date(startDate + "T00:00:00Z");
|
|
||||||
const end = new Date(endDate + "T00:00:00Z");
|
|
||||||
const results: { date: string; holiday: Holiday }[] = [];
|
|
||||||
const fetchedMonths = new Set<string>();
|
|
||||||
|
|
||||||
for (let d = new Date(start); d <= end; d.setUTCMonth(d.getUTCMonth() + 1)) {
|
|
||||||
const monthKey = `${d.getUTCFullYear()}-${d.getUTCMonth() + 1}`;
|
|
||||||
if (fetchedMonths.has(monthKey)) continue;
|
|
||||||
fetchedMonths.add(monthKey);
|
|
||||||
|
|
||||||
const url = `https://calendarific.com/api/v2/holidays?api_key=${encodeURIComponent(apiKey)}&country=${encodeURIComponent(country)}&year=${d.getUTCFullYear()}&month=${d.getUTCMonth() + 1}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) continue;
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const holidays = data.response?.holidays ?? [];
|
|
||||||
|
|
||||||
for (const h of holidays) {
|
|
||||||
const hDate = h.date?.iso?.slice(0, 10);
|
|
||||||
if (!hDate) continue;
|
|
||||||
results.push({
|
|
||||||
date: hDate,
|
|
||||||
holiday: {
|
|
||||||
name: h.name,
|
|
||||||
description: h.description,
|
|
||||||
country: h.country?.id ?? country,
|
|
||||||
type: h.type?.[0] ?? "observance",
|
|
||||||
source: "calendarific",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Calendarific range fetch failed: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchHebcalRange(startDate: string, endDate: string): Promise<{ date: string; holiday: Holiday }[]> {
|
|
||||||
try {
|
|
||||||
const url = `https://www.hebcal.com/hebcal?cfg=json&v=1&start=${startDate}&end=${endDate}&maj=on&min=on&mod=on&nx=on&ss=on`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) return [];
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const events = data.items ?? [];
|
|
||||||
|
|
||||||
return events.map((evt: any) => ({
|
|
||||||
date: evt.date?.slice(0, 10) ?? startDate,
|
|
||||||
holiday: {
|
|
||||||
name: evt.title,
|
|
||||||
description: evt.memo ?? undefined,
|
|
||||||
source: "hebcal" as const,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`HebCal range fetch failed: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetch holiday data from APIs and cache it in the DB.
|
|
||||||
* Called early (e.g. 00:05 UTC) so the post step has no network dependency.
|
|
||||||
*/
|
|
||||||
async prefetch(): Promise<void> {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Already prefetched?
|
|
||||||
const existing = db.prepare(`SELECT data FROM daily_prefetch WHERE job_name = 'holidays' AND date = ?`).get(today);
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { holidays, hebrewDate, hijriDate } = await this.fetchAllForDate(today, true);
|
|
||||||
db.prepare(`INSERT OR REPLACE INTO daily_prefetch (job_name, date, data) VALUES ('holidays', ?, ?)`)
|
|
||||||
.run(today, JSON.stringify({ holidays, hebrewDate, hijriDate }));
|
|
||||||
logger.info(`Prefetched ${holidays.length} holidays for ${today}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Holiday prefetch failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by DailyScheduler to post the morning holiday summary.
|
|
||||||
* Reads from prefetch cache first, falls back to live API call.
|
|
||||||
*/
|
|
||||||
async postHolidays(roomIds: string[]): Promise<void> {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Already posted?
|
|
||||||
const existing = db.prepare(`SELECT id FROM holidays_log WHERE date = ? LIMIT 1`).get(today);
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
// Try prefetch cache first
|
|
||||||
let holidays: Holiday[];
|
|
||||||
let hebrewDate: string | null = null;
|
|
||||||
let hijriDate: string | null = null;
|
|
||||||
|
|
||||||
const cached = db.prepare(`SELECT data FROM daily_prefetch WHERE job_name = 'holidays' AND date = ?`).get(today) as { data: string } | undefined;
|
|
||||||
if (cached) {
|
|
||||||
const parsed = JSON.parse(cached.data);
|
|
||||||
holidays = parsed.holidays;
|
|
||||||
hebrewDate = parsed.hebrewDate;
|
|
||||||
hijriDate = parsed.hijriDate;
|
|
||||||
} else {
|
|
||||||
// Fallback to live fetch
|
|
||||||
const result = await this.fetchAllForDate(today, true);
|
|
||||||
holidays = result.holidays;
|
|
||||||
hebrewDate = result.hebrewDate;
|
|
||||||
hijriDate = result.hijriDate;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (holidays.length === 0) {
|
|
||||||
logger.info("No holidays found for today, skipping post.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Build date header
|
|
||||||
const gregorianDate = new Date().toLocaleDateString("en-US", {
|
|
||||||
weekday: "long",
|
|
||||||
year: "numeric",
|
|
||||||
month: "long",
|
|
||||||
day: "numeric",
|
|
||||||
});
|
|
||||||
|
|
||||||
const dateHeader = [
|
|
||||||
gregorianDate,
|
|
||||||
hebrewDate ? `Hebrew: ${hebrewDate}` : null,
|
|
||||||
hijriDate ? `Hijri: ${hijriDate}` : null,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(" | ");
|
|
||||||
|
|
||||||
// Select featured holiday (prefer non-Western)
|
|
||||||
const featured = this.selectFeatured(holidays);
|
|
||||||
|
|
||||||
// Build sections
|
|
||||||
const asianCodesSet = new Set(Object.keys(ASIAN_COUNTRIES));
|
|
||||||
const religiousHolidays = holidays.filter(
|
|
||||||
(h) => h.source === "hebcal" || h.source === "aladhan" || h.type === "religious"
|
|
||||||
);
|
|
||||||
const asianHolidays = holidays.filter(
|
|
||||||
(h) => h.source === "calendarific" && h.country && asianCodesSet.has(h.country)
|
|
||||||
);
|
|
||||||
const otherHolidays = holidays.filter(
|
|
||||||
(h) => h.source === "calendarific" && h.type !== "religious" && (!h.country || !asianCodesSet.has(h.country))
|
|
||||||
);
|
|
||||||
|
|
||||||
const lines: string[] = [dateHeader, ""];
|
|
||||||
|
|
||||||
if (featured) {
|
|
||||||
lines.push(`Featured: ${featured.name}${featured.description ? ` — ${featured.description}` : ""}`);
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (religiousHolidays.length > 0) {
|
|
||||||
lines.push("Religious Observances:");
|
|
||||||
for (const h of religiousHolidays) {
|
|
||||||
lines.push(` - ${h.name}`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (asianHolidays.length > 0) {
|
|
||||||
lines.push("Asian Holidays:");
|
|
||||||
for (const h of asianHolidays) {
|
|
||||||
lines.push(` - ${h.name} (${countryLabel(h.country!)})`);
|
|
||||||
}
|
|
||||||
lines.push("");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (otherHolidays.length > 0) {
|
|
||||||
lines.push("Other Observances:");
|
|
||||||
for (const h of otherHolidays) {
|
|
||||||
lines.push(` - ${h.name}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = lines.join("\n").trim();
|
|
||||||
|
|
||||||
for (const roomId of roomIds) {
|
|
||||||
try {
|
|
||||||
await this.sendMessage(roomId, message);
|
|
||||||
db.prepare(`INSERT OR IGNORE INTO holidays_log (room_id, date, holidays_json) VALUES (?, ?, ?)`).run(
|
|
||||||
roomId,
|
|
||||||
today,
|
|
||||||
JSON.stringify(holidays)
|
|
||||||
);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to post holidays to ${roomId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fetch holidays for a given date from all sources (Calendarific US + Asian, HebCal, Aladhan).
|
|
||||||
*/
|
|
||||||
private async fetchAllForDate(date: string): Promise<Holiday[]>;
|
|
||||||
private async fetchAllForDate(date: string, withCalendarDates: true): Promise<{ holidays: Holiday[]; hebrewDate: string | null; hijriDate: string | null }>;
|
|
||||||
private async fetchAllForDate(date: string, withCalendarDates?: boolean): Promise<Holiday[] | { holidays: Holiday[]; hebrewDate: string | null; hijriDate: string | null }> {
|
|
||||||
const holidays: Holiday[] = [];
|
|
||||||
const asianCodes = getAsianCountryCodes();
|
|
||||||
|
|
||||||
const [calendarific, hebcal, aladhan, ...asianResults] = await Promise.allSettled([
|
|
||||||
this.fetchCalendarific(date),
|
|
||||||
this.fetchHebcal(date),
|
|
||||||
this.fetchAladhan(date),
|
|
||||||
...asianCodes.map((code) => this.fetchCalendarificForCountry(date, code)),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (calendarific.status === "fulfilled") holidays.push(...calendarific.value);
|
|
||||||
if (hebcal.status === "fulfilled") holidays.push(...hebcal.value);
|
|
||||||
if (aladhan.status === "fulfilled") holidays.push(...aladhan.value);
|
|
||||||
for (const result of asianResults) {
|
|
||||||
if (result.status === "fulfilled") holidays.push(...result.value);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (withCalendarDates) {
|
|
||||||
const hebrewDate = hebcal.status === "fulfilled" ? (hebcal.value as any).__hebrewDate ?? null : null;
|
|
||||||
const hijriDate = aladhan.status === "fulfilled" ? (aladhan.value as any).__hijriDate ?? null : null;
|
|
||||||
return { holidays, hebrewDate, hijriDate };
|
|
||||||
}
|
|
||||||
|
|
||||||
return holidays;
|
|
||||||
}
|
|
||||||
|
|
||||||
private selectFeatured(holidays: Holiday[]): Holiday | null {
|
|
||||||
// Prefer non-Western holidays
|
|
||||||
const nonWestern = holidays.filter(
|
|
||||||
(h) => !h.country || !WESTERN_COUNTRIES.has(h.country)
|
|
||||||
);
|
|
||||||
|
|
||||||
if (nonWestern.length > 0) {
|
|
||||||
return nonWestern[Math.floor(Math.random() * nonWestern.length)];
|
|
||||||
}
|
|
||||||
|
|
||||||
return holidays.length > 0 ? holidays[0] : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchCalendarific(date: string): Promise<Holiday[]> {
|
|
||||||
return this.fetchCalendarificForCountry(date, "US");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchCalendarificForCountry(date: string, country: string): Promise<Holiday[]> {
|
|
||||||
const apiKey = process.env.CALENDARIFIC_API_KEY;
|
|
||||||
if (!apiKey) return [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const [year, month, day] = date.split("-");
|
|
||||||
const url = `https://calendarific.com/api/v2/holidays?api_key=${encodeURIComponent(apiKey)}&country=${encodeURIComponent(country)}&year=${year}&month=${month}&day=${day}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Calendarific API (${country}) returned ${res.status}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const holidays = data.response?.holidays ?? [];
|
|
||||||
|
|
||||||
return holidays.map((h: any) => ({
|
|
||||||
name: h.name,
|
|
||||||
description: h.description,
|
|
||||||
country: h.country?.id ?? country,
|
|
||||||
type: h.type?.[0] ?? "observance",
|
|
||||||
source: "calendarific" as const,
|
|
||||||
}));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Calendarific fetch (${country}) failed: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchHebcal(date: string): Promise<Holiday[] & { __hebrewDate?: string }> {
|
|
||||||
try {
|
|
||||||
const [year, month, day] = date.split("-");
|
|
||||||
const url = `https://www.hebcal.com/converter?cfg=json&gy=${year}&gm=${parseInt(month)}&gd=${parseInt(day)}&g2h=1`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`HebCal API returned ${res.status}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
|
|
||||||
const result: Holiday[] & { __hebrewDate?: string } = [];
|
|
||||||
result.__hebrewDate = data.hebrew ?? null;
|
|
||||||
|
|
||||||
// Fetch events for this date
|
|
||||||
const eventsUrl = `https://www.hebcal.com/hebcal?cfg=json&v=1&start=${date}&end=${date}&maj=on&min=on&mod=on&nx=on&ss=on`;
|
|
||||||
const eventsRes = await fetch(eventsUrl);
|
|
||||||
if (eventsRes.ok) {
|
|
||||||
const eventsData = await eventsRes.json() as any;
|
|
||||||
const events = eventsData.items ?? [];
|
|
||||||
for (const evt of events) {
|
|
||||||
result.push({
|
|
||||||
name: evt.title,
|
|
||||||
description: evt.memo ?? undefined,
|
|
||||||
source: "hebcal",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`HebCal fetch failed: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchAladhan(date: string): Promise<Holiday[] & { __hijriDate?: string }> {
|
|
||||||
try {
|
|
||||||
const [year, month, day] = date.split("-");
|
|
||||||
const url = `https://api.aladhan.com/v1/timings/${day}-${month}-${year}?latitude=21.4225&longitude=39.8262`;
|
|
||||||
const res = await fetch(url, { redirect: "follow" });
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Aladhan API returned ${res.status}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const hijri = data.data?.date?.hijri;
|
|
||||||
|
|
||||||
const result: Holiday[] & { __hijriDate?: string } = [];
|
|
||||||
if (hijri) {
|
|
||||||
result.__hijriDate = `${hijri.day} ${hijri.month?.en} ${hijri.year} AH`;
|
|
||||||
|
|
||||||
// Check for holidays in the response
|
|
||||||
const holidays = hijri.holidays ?? [];
|
|
||||||
for (const name of holidays) {
|
|
||||||
result.push({
|
|
||||||
name,
|
|
||||||
source: "aladhan",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Aladhan fetch failed: ${err}`);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const OLLAMA_HOST = (() => {
|
|
||||||
const raw = process.env.OLLAMA_HOST ?? "";
|
|
||||||
return raw && !raw.startsWith("http") ? `http://${raw}` : raw;
|
|
||||||
})();
|
|
||||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? "";
|
|
||||||
const LLM_ENABLED = OLLAMA_HOST !== "" && OLLAMA_MODEL !== "";
|
|
||||||
const OLLAMA_TIMEOUT_MS = 45_000;
|
|
||||||
const OLLAMA_NUM_CTX = 16384;
|
|
||||||
|
|
||||||
interface UserProfile {
|
|
||||||
// user_stats
|
|
||||||
totalMessages: number;
|
|
||||||
totalWords: number;
|
|
||||||
totalQuestions: number;
|
|
||||||
totalExclamations: number;
|
|
||||||
totalEmojis: number;
|
|
||||||
totalLinks: number;
|
|
||||||
totalImages: number;
|
|
||||||
avgWordsPerMessage: number;
|
|
||||||
longestMessage: number;
|
|
||||||
currentStreak: number;
|
|
||||||
longestStreak: number;
|
|
||||||
hourlyDistribution: Record<string, number>;
|
|
||||||
dailyDistribution: Record<string, number>;
|
|
||||||
// users
|
|
||||||
xp: number;
|
|
||||||
level: number;
|
|
||||||
rep: number;
|
|
||||||
// potty_mouth
|
|
||||||
profanityTotal: number;
|
|
||||||
profanitySeverity: { mild: number; moderate: number; scorched: number };
|
|
||||||
// insult_log
|
|
||||||
insultsThrown: number;
|
|
||||||
insultsDirect: number;
|
|
||||||
insultsIndirect: number;
|
|
||||||
timesTargeted: number;
|
|
||||||
// trivia
|
|
||||||
triviaCorrect: number;
|
|
||||||
triviaPoints: number;
|
|
||||||
triviaStreak: number;
|
|
||||||
triviaFastestMs: number | null;
|
|
||||||
// achievements
|
|
||||||
achievements: string[];
|
|
||||||
totalAchievements: number;
|
|
||||||
// sentiment breakdown
|
|
||||||
sentiments: Record<string, number>;
|
|
||||||
// wotd
|
|
||||||
wotdCorrect: number;
|
|
||||||
wotdAttempts: number;
|
|
||||||
// activity
|
|
||||||
peakHour: number | null;
|
|
||||||
peakDay: string | null;
|
|
||||||
firstSeenDate: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
||||||
|
|
||||||
export class HowAmIPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "howami";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "howami", description: "Get roasted based on your actual stats", usage: "!howami [@user]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!this.isCommand(ctx.body, "howami")) return;
|
|
||||||
await this.handleHowAmI(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleHowAmI(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!LLM_ENABLED) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "LLM features are not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "howami");
|
|
||||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
const isSelf = targetUser === ctx.sender;
|
|
||||||
|
|
||||||
const profile = this.gatherProfile(targetUser, ctx.roomId);
|
|
||||||
if (!profile || profile.totalMessages === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No data found for ${targetUser}. They need to chat more.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const roast = await this.generateRoast(targetUser, profile, isSelf);
|
|
||||||
if (roast) {
|
|
||||||
await this.sendMessage(ctx.roomId, roast);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "The roast machine broke. Try again later.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`howami roast generation failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "The roast machine broke. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private gatherProfile(userId: string, roomId: string): UserProfile | null {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// user_stats
|
|
||||||
const stats = db
|
|
||||||
.prepare(`SELECT * FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as any;
|
|
||||||
if (!stats) return null;
|
|
||||||
|
|
||||||
// users (xp/level/rep)
|
|
||||||
const user = db
|
|
||||||
.prepare(`SELECT xp, level, rep FROM users WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as { xp: number; level: number; rep: number } | undefined;
|
|
||||||
|
|
||||||
// potty_mouth
|
|
||||||
const potty = db
|
|
||||||
.prepare(`SELECT total, severity_1, severity_2, severity_3 FROM potty_mouth WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as { total: number; severity_1: number; severity_2: number; severity_3: number } | undefined;
|
|
||||||
|
|
||||||
// insult_log
|
|
||||||
const insults = db
|
|
||||||
.prepare(`SELECT total_thrown, direct_thrown, indirect_thrown, times_targeted FROM insult_log WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as { total_thrown: number; direct_thrown: number; indirect_thrown: number; times_targeted: number } | undefined;
|
|
||||||
|
|
||||||
// trivia
|
|
||||||
const trivia = db
|
|
||||||
.prepare(`SELECT total_correct, total_points, best_streak, fastest_ms FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as { total_correct: number; total_points: number; best_streak: number; fastest_ms: number | null } | undefined;
|
|
||||||
|
|
||||||
// achievements
|
|
||||||
const achievementRows = db
|
|
||||||
.prepare(`SELECT achievement_key FROM achievements WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.all(userId, roomId) as { achievement_key: string }[];
|
|
||||||
|
|
||||||
// sentiment breakdown from aggregate table
|
|
||||||
const sentimentRow = db
|
|
||||||
.prepare(`SELECT * FROM sentiment_stats WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as { neutral: number; happy: number; sad: number; angry: number; excited: number; funny: number; love: number; scared: number; total_classified: number } | undefined;
|
|
||||||
const sentiments: Record<string, number> = {};
|
|
||||||
if (sentimentRow) {
|
|
||||||
for (const s of ["neutral", "happy", "sad", "angry", "excited", "funny", "love", "scared"] as const) {
|
|
||||||
if (sentimentRow[s] > 0) sentiments[s] = sentimentRow[s];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// wotd
|
|
||||||
const wotdRow = db
|
|
||||||
.prepare(`SELECT COUNT(*) as attempts, COALESCE(SUM(CASE WHEN xp_awarded > 0 THEN 1 ELSE 0 END), 0) as correct FROM wotd_usage WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as { attempts: number; correct: number };
|
|
||||||
|
|
||||||
// first seen
|
|
||||||
const firstSeen = db
|
|
||||||
.prepare(`SELECT MIN(date) as first_date FROM daily_activity WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as { first_date: string | null };
|
|
||||||
|
|
||||||
// hourly/daily distributions
|
|
||||||
const hourly: Record<string, number> = JSON.parse(stats.hourly_distribution || "{}");
|
|
||||||
const daily: Record<string, number> = JSON.parse(stats.daily_distribution || "{}");
|
|
||||||
|
|
||||||
// peak hour
|
|
||||||
let peakHour: number | null = null;
|
|
||||||
let peakHourCount = 0;
|
|
||||||
for (const [h, count] of Object.entries(hourly)) {
|
|
||||||
if (count > peakHourCount) {
|
|
||||||
peakHour = parseInt(h);
|
|
||||||
peakHourCount = count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// peak day
|
|
||||||
let peakDay: string | null = null;
|
|
||||||
let peakDayCount = 0;
|
|
||||||
for (const [d, count] of Object.entries(daily)) {
|
|
||||||
if (count > peakDayCount) {
|
|
||||||
peakDay = DAY_NAMES[parseInt(d)] ?? null;
|
|
||||||
peakDayCount = count;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalMessages: stats.total_messages,
|
|
||||||
totalWords: stats.total_words,
|
|
||||||
totalQuestions: stats.total_questions,
|
|
||||||
totalExclamations: stats.total_exclamations,
|
|
||||||
totalEmojis: stats.total_emojis,
|
|
||||||
totalLinks: stats.total_links,
|
|
||||||
totalImages: stats.total_images,
|
|
||||||
avgWordsPerMessage: stats.avg_words_per_message,
|
|
||||||
longestMessage: stats.longest_message,
|
|
||||||
currentStreak: stats.current_streak,
|
|
||||||
longestStreak: stats.longest_streak,
|
|
||||||
hourlyDistribution: hourly,
|
|
||||||
dailyDistribution: daily,
|
|
||||||
xp: user?.xp ?? 0,
|
|
||||||
level: user?.level ?? 1,
|
|
||||||
rep: user?.rep ?? 0,
|
|
||||||
profanityTotal: potty?.total ?? 0,
|
|
||||||
profanitySeverity: {
|
|
||||||
mild: potty?.severity_1 ?? 0,
|
|
||||||
moderate: potty?.severity_2 ?? 0,
|
|
||||||
scorched: potty?.severity_3 ?? 0,
|
|
||||||
},
|
|
||||||
insultsThrown: insults?.total_thrown ?? 0,
|
|
||||||
insultsDirect: insults?.direct_thrown ?? 0,
|
|
||||||
insultsIndirect: insults?.indirect_thrown ?? 0,
|
|
||||||
timesTargeted: insults?.times_targeted ?? 0,
|
|
||||||
triviaCorrect: trivia?.total_correct ?? 0,
|
|
||||||
triviaPoints: trivia?.total_points ?? 0,
|
|
||||||
triviaStreak: trivia?.best_streak ?? 0,
|
|
||||||
triviaFastestMs: trivia?.fastest_ms ?? null,
|
|
||||||
achievements: achievementRows.map((a) => a.achievement_key),
|
|
||||||
totalAchievements: achievementRows.length,
|
|
||||||
sentiments,
|
|
||||||
wotdCorrect: wotdRow?.correct ?? 0,
|
|
||||||
wotdAttempts: wotdRow?.attempts ?? 0,
|
|
||||||
peakHour,
|
|
||||||
peakDay,
|
|
||||||
firstSeenDate: firstSeen?.first_date ?? null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private buildStatsSummary(userId: string, p: UserProfile): string {
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
lines.push(`User: ${userId}`);
|
|
||||||
if (p.firstSeenDate) lines.push(`First seen: ${p.firstSeenDate}`);
|
|
||||||
lines.push(`Level ${p.level} | ${p.xp} XP | ${p.rep} reputation`);
|
|
||||||
lines.push(`Messages: ${p.totalMessages} | Words: ${p.totalWords} | Avg words/msg: ${p.avgWordsPerMessage.toFixed(1)}`);
|
|
||||||
|
|
||||||
const questionPct = p.totalMessages > 0 ? ((p.totalQuestions / p.totalMessages) * 100).toFixed(1) : "0";
|
|
||||||
const exclamationPct = p.totalMessages > 0 ? ((p.totalExclamations / p.totalMessages) * 100).toFixed(1) : "0";
|
|
||||||
lines.push(`Questions: ${p.totalQuestions} (${questionPct}% of messages) | Exclamations: ${p.totalExclamations} (${exclamationPct}%)`);
|
|
||||||
|
|
||||||
lines.push(`Emojis: ${p.totalEmojis} | Links: ${p.totalLinks} | Images: ${p.totalImages}`);
|
|
||||||
lines.push(`Longest message: ${p.longestMessage} chars`);
|
|
||||||
lines.push(`Activity streak: ${p.currentStreak}d current, ${p.longestStreak}d record`);
|
|
||||||
|
|
||||||
if (p.peakHour !== null) lines.push(`Most active hour: ${p.peakHour}:00 UTC`);
|
|
||||||
if (p.peakDay) lines.push(`Most active day: ${p.peakDay}`);
|
|
||||||
|
|
||||||
if (p.profanityTotal > 0) {
|
|
||||||
lines.push(`Profanity: ${p.profanityTotal} total (mild: ${p.profanitySeverity.mild}, moderate: ${p.profanitySeverity.moderate}, scorched: ${p.profanitySeverity.scorched})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.insultsThrown > 0 || p.timesTargeted > 0) {
|
|
||||||
lines.push(`Insults thrown: ${p.insultsThrown} (direct: ${p.insultsDirect}, indirect: ${p.insultsIndirect}) | Times targeted: ${p.timesTargeted}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.triviaCorrect > 0) {
|
|
||||||
const fastestStr = p.triviaFastestMs != null ? `${(p.triviaFastestMs / 1000).toFixed(2)}s` : "N/A";
|
|
||||||
lines.push(`Trivia: ${p.triviaCorrect} correct, ${p.triviaPoints} pts, best streak: ${p.triviaStreak}, fastest: ${fastestStr}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.wotdAttempts > 0) {
|
|
||||||
lines.push(`WOTD: ${p.wotdCorrect}/${p.wotdAttempts} correct attempts`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sentiment breakdown
|
|
||||||
const totalClassified = Object.values(p.sentiments).reduce((a, b) => a + b, 0);
|
|
||||||
if (totalClassified > 0) {
|
|
||||||
const topSentiments = Object.entries(p.sentiments)
|
|
||||||
.filter(([s]) => s !== "neutral")
|
|
||||||
.sort((a, b) => b[1] - a[1])
|
|
||||||
.slice(0, 3)
|
|
||||||
.map(([s, c]) => `${s}: ${c}`);
|
|
||||||
if (topSentiments.length > 0) {
|
|
||||||
lines.push(`Top sentiments: ${topSentiments.join(", ")} (out of ${totalClassified} classified)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (p.achievements.length > 0) {
|
|
||||||
lines.push(`Achievements (${p.totalAchievements}): ${p.achievements.join(", ")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async generateRoast(userId: string, profile: UserProfile, isSelf: boolean): Promise<string | null> {
|
|
||||||
const statsSummary = this.buildStatsSummary(userId, profile);
|
|
||||||
const target = isSelf ? "the user themselves (they asked for it)" : `${userId} (someone else asked about them)`;
|
|
||||||
|
|
||||||
const prompt = `You are Freebee, a snarky but affectionate chat bot in a private friend group. Someone just used !howami, and you need to roast ${target} based on their ACTUAL stats below.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Write a single paragraph roast, 2-4 sentences max
|
|
||||||
- Be witty and specific — reference their actual numbers and patterns
|
|
||||||
- Tone: playful roast between friends, not mean-spirited. Think comedy roast, not bullying
|
|
||||||
- Find the funny angle in their data (night owl? question machine? potty mouth? lurker? emoji addict?)
|
|
||||||
- If they have notable achievements, work those in
|
|
||||||
- If their stats reveal an obvious personality type, call it out
|
|
||||||
- Do NOT use bullet points, headers, or formatting. Just flowing text
|
|
||||||
- Do NOT start with "Well," or "Oh," or greetings. Jump straight into the roast
|
|
||||||
- Keep it under 500 characters
|
|
||||||
|
|
||||||
Stats:
|
|
||||||
${statsSummary}
|
|
||||||
|
|
||||||
Write the roast now. Raw text only, no quotes or markdown.`;
|
|
||||||
|
|
||||||
const res = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: OLLAMA_MODEL,
|
|
||||||
prompt,
|
|
||||||
stream: false,
|
|
||||||
options: { num_ctx: OLLAMA_NUM_CTX, temperature: 0.9 },
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(OLLAMA_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Ollama API returned ${res.status} for howami`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { response: string };
|
|
||||||
let roast = data.response?.trim();
|
|
||||||
if (!roast) return null;
|
|
||||||
|
|
||||||
// Clean up common LLM quirks
|
|
||||||
roast = roast.replace(/^["']|["']$/g, ""); // strip wrapping quotes
|
|
||||||
roast = roast.replace(/^#+\s*/gm, ""); // strip markdown headers
|
|
||||||
roast = roast.replace(/\*\*/g, ""); // strip bold markers
|
|
||||||
|
|
||||||
return roast;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,674 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { XpPlugin } from "./xp";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const RAW_OLLAMA_HOST = process.env.OLLAMA_HOST ?? "";
|
|
||||||
const OLLAMA_HOST = RAW_OLLAMA_HOST && !RAW_OLLAMA_HOST.startsWith("http") ? `http://${RAW_OLLAMA_HOST}` : RAW_OLLAMA_HOST;
|
|
||||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? "";
|
|
||||||
const LLM_ENABLED = OLLAMA_HOST !== "" && OLLAMA_MODEL !== "";
|
|
||||||
const NOT_CONFIGURED = "LLM features are not configured.";
|
|
||||||
const WOTD_XP = 50;
|
|
||||||
const REP_XP_BONUS = 5;
|
|
||||||
const REP_COOLDOWN_HOURS = 24;
|
|
||||||
const OLLAMA_TIMEOUT_MS = 30_000;
|
|
||||||
const MAX_QUEUE_SIZE = 50;
|
|
||||||
const OLLAMA_NUM_CTX = 16384;
|
|
||||||
const SAMPLE_RATE = parseFloat(process.env.LLM_SAMPLE_RATE ?? "0.15");
|
|
||||||
|
|
||||||
const PROFANITY_KEYWORDS = [
|
|
||||||
"fuck", "shit", "damn", "hell", "ass", "bitch", "bastard", "crap",
|
|
||||||
"dick", "piss", "cock", "cunt", "twat", "wank", "bollocks", "arse",
|
|
||||||
"motherfucker", "bullshit", "horseshit", "goddamn", "dammit", "asshole",
|
|
||||||
"shitty", "bitchy", "fucked", "fucking", "fucker", "dumbass", "jackass",
|
|
||||||
"dipshit", "wtf", "stfu", "lmfao",
|
|
||||||
];
|
|
||||||
|
|
||||||
type Sentiment = "neutral" | "happy" | "sad" | "angry" | "excited" | "funny" | "love" | "scared";
|
|
||||||
|
|
||||||
interface ClassificationResult {
|
|
||||||
profanity: boolean;
|
|
||||||
profanity_severity: number;
|
|
||||||
insult: boolean;
|
|
||||||
insult_type: "direct" | "indirect" | null;
|
|
||||||
insult_target: string | null;
|
|
||||||
insult_severity: number;
|
|
||||||
sentiment: Sentiment;
|
|
||||||
gratitude: boolean;
|
|
||||||
gratitude_toward: string | null;
|
|
||||||
wotd_used: boolean;
|
|
||||||
wotd_correct: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface QueueItem {
|
|
||||||
ctx: MessageContext;
|
|
||||||
wotd: { word: string; definition: string | null; example: string | null; part_of_speech: string | null } | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BACKOFF_INITIAL_MS = 5_000;
|
|
||||||
const BACKOFF_MAX_MS = 5 * 60 * 1000; // 5 minutes
|
|
||||||
|
|
||||||
export class LlmPassivePlugin extends Plugin {
|
|
||||||
private xpPlugin: XpPlugin;
|
|
||||||
private botUserId: string = "";
|
|
||||||
private queue: QueueItem[] = [];
|
|
||||||
private processing = false;
|
|
||||||
private backoffMs = 0;
|
|
||||||
private backoffUntil = 0;
|
|
||||||
private consecutiveFailures = 0;
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
|
||||||
super(client);
|
|
||||||
this.xpPlugin = xpPlugin;
|
|
||||||
const uid = client.getUserId();
|
|
||||||
if (typeof uid === "string") {
|
|
||||||
this.botUserId = uid;
|
|
||||||
} else if (uid) {
|
|
||||||
(uid as Promise<string>).then((id: string) => { this.botUserId = id; }).catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "llm-passive";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "potty", description: "Potty mouth stats", usage: "!potty [@user]" },
|
|
||||||
{ name: "pottyboard", description: "Potty mouth leaderboard" },
|
|
||||||
{ name: "insults", description: "Insult stats", usage: "!insults [@user]" },
|
|
||||||
{ name: "insultboard", description: "Most prolific insulters leaderboard" },
|
|
||||||
{ name: "wotd attempts", description: "Who tried today's WOTD", usage: "!wotd attempts" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!LLM_ENABLED) {
|
|
||||||
// Still handle commands to return the not-configured message
|
|
||||||
const cmds = ["potty", "pottyboard", "insults", "insultboard"];
|
|
||||||
for (const cmd of cmds) {
|
|
||||||
if (this.isCommand(ctx.body, cmd)) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, NOT_CONFIGURED);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (this.isCommand(ctx.body, "wotd attempts")) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, NOT_CONFIGURED);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle commands
|
|
||||||
if (this.isCommand(ctx.body, "wotd attempts")) {
|
|
||||||
await this.handleWotdAttempts(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.isCommand(ctx.body, "pottyboard")) {
|
|
||||||
await this.handlePottyboard(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.isCommand(ctx.body, "potty")) {
|
|
||||||
await this.handlePotty(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.isCommand(ctx.body, "insultboard")) {
|
|
||||||
await this.handleInsultboard(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (this.isCommand(ctx.body, "insults")) {
|
|
||||||
await this.handleInsults(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Passive classification — pre-filter to avoid sending every message to Ollama
|
|
||||||
// Always classify: keyword matches, @mentions, WOTD usage, non-ASCII text
|
|
||||||
// Otherwise: sample a percentage of remaining messages to catch what keywords miss
|
|
||||||
const bodyLower = ctx.body.toLowerCase();
|
|
||||||
const words = bodyLower.split(/\s+/);
|
|
||||||
|
|
||||||
const wotd = this.getTodaysWotd(ctx.roomId);
|
|
||||||
|
|
||||||
const hasProfanity = words.some((w) => {
|
|
||||||
const cleaned = w.replace(/[^a-z]/g, "");
|
|
||||||
return PROFANITY_KEYWORDS.includes(cleaned);
|
|
||||||
});
|
|
||||||
const hasMention = ctx.body.includes("@");
|
|
||||||
const hasWotd = wotd ? new RegExp(`\\b${wotd.word.toLowerCase().replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`).test(bodyLower) : false;
|
|
||||||
const hasNonAscii = /[^\x00-\x7F]/.test(ctx.body);
|
|
||||||
const hasThanks = /\b(?:thanks?|thank\s*you|thx|ty|tysm|tyvm|cheers|kudos|props)\b/i.test(ctx.body);
|
|
||||||
|
|
||||||
if (!hasProfanity && !hasMention && !hasWotd && !hasNonAscii && !hasThanks) {
|
|
||||||
// Random sample of remaining messages so the LLM can catch
|
|
||||||
// profanity/insults in any language or phrasing the keyword list misses
|
|
||||||
if (Math.random() >= SAMPLE_RATE) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Enqueue for classification (drop if queue is full to avoid unbounded growth)
|
|
||||||
if (this.queue.length >= MAX_QUEUE_SIZE) {
|
|
||||||
logger.warn(`LLM classification queue full (${MAX_QUEUE_SIZE}), dropping message`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.queue.push({ ctx, wotd });
|
|
||||||
this.processQueue();
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTodaysWotd(roomId: string): { word: string; definition: string | null; example: string | null; part_of_speech: string | null } | null {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
// Try today first, fall back to the most recent word (covers the gap before the new word is posted)
|
|
||||||
let row = db
|
|
||||||
.prepare(`SELECT word, definition, example, part_of_speech FROM wotd_log WHERE date = ? AND room_id = ? LIMIT 1`)
|
|
||||||
.get(today, roomId) as { word: string; definition: string | null; example: string | null; part_of_speech: string | null } | undefined;
|
|
||||||
if (!row) {
|
|
||||||
row = db
|
|
||||||
.prepare(`SELECT word, definition, example, part_of_speech FROM wotd_log WHERE room_id = ? ORDER BY date DESC LIMIT 1`)
|
|
||||||
.get(roomId) as typeof row;
|
|
||||||
}
|
|
||||||
return row ?? null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async processQueue(): Promise<void> {
|
|
||||||
if (this.processing) return;
|
|
||||||
this.processing = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
while (this.queue.length > 0) {
|
|
||||||
// If in backoff, wait then retry
|
|
||||||
const now = Date.now();
|
|
||||||
if (this.backoffUntil > now) {
|
|
||||||
const wait = this.backoffUntil - now;
|
|
||||||
logger.info(`LLM backoff: waiting ${Math.ceil(wait / 1000)}s before retry (${this.queue.length} queued)`);
|
|
||||||
await new Promise((r) => setTimeout(r, wait));
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = this.queue.shift()!;
|
|
||||||
try {
|
|
||||||
await this.classify(item);
|
|
||||||
// Success — reset backoff
|
|
||||||
if (this.consecutiveFailures > 0) {
|
|
||||||
logger.info("LLM back online — resuming classification");
|
|
||||||
}
|
|
||||||
this.consecutiveFailures = 0;
|
|
||||||
this.backoffMs = 0;
|
|
||||||
this.backoffUntil = 0;
|
|
||||||
} catch (err) {
|
|
||||||
this.consecutiveFailures++;
|
|
||||||
this.backoffMs = Math.min(
|
|
||||||
this.backoffMs === 0 ? BACKOFF_INITIAL_MS : this.backoffMs * 2,
|
|
||||||
BACKOFF_MAX_MS
|
|
||||||
);
|
|
||||||
this.backoffUntil = Date.now() + this.backoffMs;
|
|
||||||
logger.error(`LLM classification failed (${this.consecutiveFailures}x, next retry in ${this.backoffMs / 1000}s): ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
this.processing = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async classify(item: QueueItem): Promise<void> {
|
|
||||||
const { ctx, wotd } = item;
|
|
||||||
|
|
||||||
const wotdWord = wotd?.word ?? "none";
|
|
||||||
const wotdDef = wotd?.definition ?? "N/A";
|
|
||||||
const wotdPos = wotd?.part_of_speech ?? "N/A";
|
|
||||||
const wotdExample = wotd?.example ?? "N/A";
|
|
||||||
|
|
||||||
const prompt = `You are a passive classifier for a private progressive friend group's chat.
|
|
||||||
Affectionate trash talk between friends is normal here — do NOT classify casual banter, questions, or playful teasing as insults.
|
|
||||||
insult should ONLY be true for messages with clear hostile intent to demean or offend someone. Asking questions, using slang, or joking around is NOT an insult.
|
|
||||||
The bot's user ID is ${this.botUserId}. If someone directly insults the bot (e.g. "stupid bot", "you suck"), set insult_target to "${this.botUserId}".
|
|
||||||
Analyze the message and respond ONLY with valid JSON, no other text.
|
|
||||||
|
|
||||||
Today's word of the day: ${wotdWord}
|
|
||||||
Definition: ${wotdDef}
|
|
||||||
Part of speech: ${wotdPos}
|
|
||||||
Example: ${wotdExample}
|
|
||||||
|
|
||||||
Message author: ${ctx.sender}
|
|
||||||
Message: ${ctx.body}
|
|
||||||
|
|
||||||
{"profanity":boolean,"profanity_severity":0|1|2|3,"insult":boolean,"insult_type":"direct"|"indirect"|null,"insult_target":"@mxid or null","insult_severity":0|1|2|3,"sentiment":"neutral"|"happy"|"sad"|"angry"|"excited"|"funny"|"love"|"scared","gratitude":boolean,"gratitude_toward":"@mxid or null","wotd_used":boolean,"wotd_correct":boolean}
|
|
||||||
|
|
||||||
insult_target should be the @mxid of the person being insulted, or null if no specific target. If the bot itself is insulted, use "${this.botUserId}".
|
|
||||||
gratitude is TRUE for genuine thanks/appreciation ("thank you for helping", "thanks for the useful feedback", "ty that worked!"). Sarcastic thanks ("thanks for nothing", "gee thanks for the unhelpful advice") is NOT gratitude. When in doubt, lean toward TRUE if the message tone is positive. gratitude_toward should be the @mxid of the person being thanked, or null if no specific person is mentioned.
|
|
||||||
wotd_used is TRUE only when the word appears as a STANDALONE word in the message (not as a substring of another word). wotd_correct is TRUE only when the standalone word is used correctly per its definition and part of speech. Typing the word out of context, referencing it meta, or awkward forced insertion does NOT qualify.`;
|
|
||||||
|
|
||||||
const res = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: OLLAMA_MODEL,
|
|
||||||
prompt,
|
|
||||||
stream: false,
|
|
||||||
options: { num_ctx: OLLAMA_NUM_CTX },
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(OLLAMA_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Ollama API returned ${res.status}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { response: string };
|
|
||||||
const result = this.parseClassification(data.response);
|
|
||||||
if (!result) {
|
|
||||||
logger.warn(`Failed to parse Ollama response: ${data.response}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.info(`LLM classified "${ctx.body.substring(0, 50)}": profanity=${result.profanity}, insult=${result.insult}${result.insult ? ` (target=${result.insult_target}, type=${result.insult_type})` : ""}, sentiment=${result.sentiment}, gratitude=${result.gratitude}${result.gratitude ? ` (toward=${result.gratitude_toward})` : ""}, wotd=${result.wotd_used}`);
|
|
||||||
this.storeResult(ctx, result);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Attempt to parse the LLM response as a ClassificationResult.
|
|
||||||
* Small models frequently produce malformed JSON — this handles:
|
|
||||||
* - Extra text / markdown fences around the JSON object
|
|
||||||
* - Single quotes instead of double quotes
|
|
||||||
* - Trailing commas before closing braces/brackets
|
|
||||||
* - Unquoted keys
|
|
||||||
* - Boolean strings ("true"/"false") instead of literal booleans
|
|
||||||
* - "none" / "null" strings instead of null
|
|
||||||
* - Missing or extra fields
|
|
||||||
*/
|
|
||||||
private parseClassification(raw: string): ClassificationResult | null {
|
|
||||||
// Step 1: Try direct parse first (fast path)
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw);
|
|
||||||
return this.normalizeResult(parsed);
|
|
||||||
} catch { /* fall through to repair */ }
|
|
||||||
|
|
||||||
// Step 2: Extract JSON object from surrounding text / markdown fences
|
|
||||||
let cleaned = raw;
|
|
||||||
|
|
||||||
// Strip markdown code fences
|
|
||||||
cleaned = cleaned.replace(/```(?:json)?\s*/gi, "").replace(/```/g, "");
|
|
||||||
|
|
||||||
// Find the outermost { ... } block
|
|
||||||
const firstBrace = cleaned.indexOf("{");
|
|
||||||
const lastBrace = cleaned.lastIndexOf("}");
|
|
||||||
if (firstBrace === -1 || lastBrace === -1 || lastBrace <= firstBrace) return null;
|
|
||||||
cleaned = cleaned.slice(firstBrace, lastBrace + 1);
|
|
||||||
|
|
||||||
// Step 3: Repair common issues
|
|
||||||
// Replace single quotes with double quotes (but not apostrophes inside words)
|
|
||||||
cleaned = cleaned.replace(/'/g, '"');
|
|
||||||
|
|
||||||
// Remove trailing commas before } or ]
|
|
||||||
cleaned = cleaned.replace(/,\s*([}\]])/g, "$1");
|
|
||||||
|
|
||||||
// Quote unquoted keys: word-chars followed by a colon
|
|
||||||
cleaned = cleaned.replace(/(?<=[\s,{])(\w+)\s*:/g, '"$1":');
|
|
||||||
|
|
||||||
// Try parsing again
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(cleaned);
|
|
||||||
return this.normalizeResult(parsed);
|
|
||||||
} catch { /* fall through to field-level extraction */ }
|
|
||||||
|
|
||||||
// Step 4: Last resort — regex extraction of individual fields
|
|
||||||
try {
|
|
||||||
const extract = (key: string): string | undefined => {
|
|
||||||
const m = cleaned.match(new RegExp(`"${key}"\\s*:\\s*("(?:[^"\\\\]|\\\\.)*"|[^,}\\]]+)`, "i"));
|
|
||||||
return m?.[1]?.trim();
|
|
||||||
};
|
|
||||||
|
|
||||||
const toBool = (v: string | undefined): boolean => {
|
|
||||||
if (!v) return false;
|
|
||||||
const s = v.replace(/"/g, "").toLowerCase();
|
|
||||||
return s === "true" || s === "1";
|
|
||||||
};
|
|
||||||
|
|
||||||
const toNum = (v: string | undefined): number => {
|
|
||||||
if (!v) return 0;
|
|
||||||
const n = parseInt(v.replace(/"/g, ""), 10);
|
|
||||||
return isNaN(n) ? 0 : n;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toNullableStr = (v: string | undefined): string | null => {
|
|
||||||
if (!v) return null;
|
|
||||||
const s = v.replace(/^"|"$/g, "").trim();
|
|
||||||
if (!s || s === "null" || s === "none" || s === "N/A") return null;
|
|
||||||
return s;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALID_SENTIMENTS: Sentiment[] = ["neutral", "happy", "sad", "angry", "excited", "funny", "love", "scared"];
|
|
||||||
const toSentiment = (v: string | undefined): Sentiment => {
|
|
||||||
const s = (v ?? "neutral").replace(/^"|"$/g, "").trim().toLowerCase() as Sentiment;
|
|
||||||
return VALID_SENTIMENTS.includes(s) ? s : "neutral";
|
|
||||||
};
|
|
||||||
|
|
||||||
return this.normalizeResult({
|
|
||||||
profanity: toBool(extract("profanity")),
|
|
||||||
profanity_severity: toNum(extract("profanity_severity")),
|
|
||||||
insult: toBool(extract("insult")),
|
|
||||||
insult_type: toNullableStr(extract("insult_type")),
|
|
||||||
insult_target: toNullableStr(extract("insult_target")),
|
|
||||||
insult_severity: toNum(extract("insult_severity")),
|
|
||||||
sentiment: toSentiment(extract("sentiment")),
|
|
||||||
gratitude: toBool(extract("gratitude")),
|
|
||||||
gratitude_toward: toNullableStr(extract("gratitude_toward")),
|
|
||||||
wotd_used: toBool(extract("wotd_used")),
|
|
||||||
wotd_correct: toBool(extract("wotd_correct")),
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Coerce a parsed object into a valid ClassificationResult,
|
|
||||||
* fixing type mismatches (e.g. "true" vs true, "none" vs null).
|
|
||||||
*/
|
|
||||||
private normalizeResult(obj: any): ClassificationResult | null {
|
|
||||||
if (!obj || typeof obj !== "object") return null;
|
|
||||||
|
|
||||||
const toBool = (v: any): boolean => {
|
|
||||||
if (typeof v === "boolean") return v;
|
|
||||||
if (typeof v === "string") return v.toLowerCase() === "true" || v === "1";
|
|
||||||
return !!v;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toSeverity = (v: any): number => {
|
|
||||||
const n = typeof v === "number" ? v : parseInt(String(v), 10);
|
|
||||||
if (isNaN(n) || n < 0) return 0;
|
|
||||||
if (n > 3) return 3;
|
|
||||||
return n;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toNullableStr = (v: any): string | null => {
|
|
||||||
if (v == null) return null;
|
|
||||||
const s = String(v).trim();
|
|
||||||
if (s === "" || s === "null" || s === "none" || s === "N/A") return null;
|
|
||||||
return s;
|
|
||||||
};
|
|
||||||
|
|
||||||
const VALID_SENTIMENTS: Sentiment[] = ["neutral", "happy", "sad", "angry", "excited", "funny", "love", "scared"];
|
|
||||||
const toSentiment = (v: any): Sentiment => {
|
|
||||||
const s = String(v ?? "neutral").toLowerCase().trim() as Sentiment;
|
|
||||||
return VALID_SENTIMENTS.includes(s) ? s : "neutral";
|
|
||||||
};
|
|
||||||
|
|
||||||
const insultType = toNullableStr(obj.insult_type);
|
|
||||||
|
|
||||||
return {
|
|
||||||
profanity: toBool(obj.profanity),
|
|
||||||
profanity_severity: toSeverity(obj.profanity_severity),
|
|
||||||
insult: toBool(obj.insult),
|
|
||||||
insult_type: insultType === "direct" || insultType === "indirect" ? insultType : null,
|
|
||||||
insult_target: toNullableStr(obj.insult_target),
|
|
||||||
insult_severity: toSeverity(obj.insult_severity),
|
|
||||||
sentiment: toSentiment(obj.sentiment),
|
|
||||||
gratitude: toBool(obj.gratitude),
|
|
||||||
gratitude_toward: toNullableStr(obj.gratitude_toward),
|
|
||||||
wotd_used: toBool(obj.wotd_used),
|
|
||||||
wotd_correct: toBool(obj.wotd_correct),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private storeResult(ctx: MessageContext, r: ClassificationResult): void {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Always insert classification
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO llm_classifications (user_id, room_id, event_id, profanity, profanity_severity, insult, insult_type, insult_target, insult_severity, sentiment, gratitude, gratitude_toward, wotd_used, wotd_correct)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(
|
|
||||||
ctx.sender, ctx.roomId, ctx.eventId,
|
|
||||||
r.profanity ? 1 : 0, r.profanity_severity,
|
|
||||||
r.insult ? 1 : 0, r.insult_type, r.insult_target, r.insult_severity, r.sentiment,
|
|
||||||
r.gratitude ? 1 : 0, r.gratitude_toward,
|
|
||||||
r.wotd_used ? 1 : 0, r.wotd_correct ? 1 : 0
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sentiment aggregate
|
|
||||||
const sentCol = r.sentiment as string;
|
|
||||||
const validSentiments = ["neutral", "happy", "sad", "angry", "excited", "funny", "love", "scared"];
|
|
||||||
if (validSentiments.includes(sentCol)) {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO sentiment_stats (user_id, room_id, ${sentCol}, total_classified)
|
|
||||||
VALUES (?, ?, 1, 1)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
|
||||||
${sentCol} = ${sentCol} + 1,
|
|
||||||
total_classified = total_classified + 1
|
|
||||||
`).run(ctx.sender, ctx.roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Profanity tracking
|
|
||||||
if (r.profanity) {
|
|
||||||
const sevCol = `severity_${r.profanity_severity}` as "severity_1" | "severity_2" | "severity_3";
|
|
||||||
if (r.profanity_severity >= 1 && r.profanity_severity <= 3) {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO potty_mouth (user_id, room_id, total, severity_1, severity_2, severity_3)
|
|
||||||
VALUES (?, ?, 1, ?, ?, ?)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
|
||||||
total = total + 1,
|
|
||||||
${sevCol} = ${sevCol} + 1
|
|
||||||
`).run(
|
|
||||||
ctx.sender, ctx.roomId,
|
|
||||||
r.profanity_severity === 1 ? 1 : 0,
|
|
||||||
r.profanity_severity === 2 ? 1 : 0,
|
|
||||||
r.profanity_severity === 3 ? 1 : 0
|
|
||||||
);
|
|
||||||
|
|
||||||
const pottyEmoji = ["\uD83E\uDEE3", "\uD83D\uDE32", "\uD83E\uDD2C"][r.profanity_severity - 1]; // 🫣 😲 🤬
|
|
||||||
this.sendReact(ctx.roomId, ctx.eventId, pottyEmoji);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Insult tracking
|
|
||||||
if (r.insult) {
|
|
||||||
const isDirect = r.insult_type === "direct";
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO insult_log (user_id, room_id, total_thrown, direct_thrown, indirect_thrown, times_targeted)
|
|
||||||
VALUES (?, ?, 1, ?, ?, 0)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
|
||||||
total_thrown = total_thrown + 1,
|
|
||||||
direct_thrown = direct_thrown + ?,
|
|
||||||
indirect_thrown = indirect_thrown + ?
|
|
||||||
`).run(
|
|
||||||
ctx.sender, ctx.roomId,
|
|
||||||
isDirect ? 1 : 0,
|
|
||||||
isDirect ? 0 : 1,
|
|
||||||
isDirect ? 1 : 0,
|
|
||||||
isDirect ? 0 : 1
|
|
||||||
);
|
|
||||||
|
|
||||||
const insultEmoji = r.insult_target === this.botUserId ? "\uD83D\uDD95" // 🖕 insulted the bot
|
|
||||||
: r.insult_type === "direct" ? "\uD83C\uDFAF" : "\uD83D\uDCA8"; // 🎯 direct, 💨 indirect
|
|
||||||
this.sendReact(ctx.roomId, ctx.eventId, insultEmoji);
|
|
||||||
|
|
||||||
// Track target
|
|
||||||
if (r.insult_target) {
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO insult_log (user_id, room_id, total_thrown, direct_thrown, indirect_thrown, times_targeted)
|
|
||||||
VALUES (?, ?, 0, 0, 0, 1)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
|
||||||
times_targeted = times_targeted + 1
|
|
||||||
`).run(r.insult_target, ctx.roomId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// WOTD tracking
|
|
||||||
if (r.wotd_used) {
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const xpAwarded = r.wotd_correct ? WOTD_XP : 0;
|
|
||||||
|
|
||||||
if (r.wotd_correct) {
|
|
||||||
this.xpPlugin.grantXp(ctx.sender, ctx.roomId, WOTD_XP, "wotd_correct");
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO wotd_usage (user_id, room_id, date, xp_awarded)
|
|
||||||
VALUES (?, ?, ?, ?)
|
|
||||||
ON CONFLICT(user_id, room_id, date) DO UPDATE SET
|
|
||||||
xp_awarded = MAX(xp_awarded, ?)
|
|
||||||
`).run(ctx.sender, ctx.roomId, today, xpAwarded, xpAwarded);
|
|
||||||
|
|
||||||
// React so the user knows their WOTD attempt was detected
|
|
||||||
const emoji = r.wotd_correct ? "\uD83D\uDCD6" : "\uD83E\uDD14"; // 📖 if correct, 🤔 if not
|
|
||||||
this.sendReact(ctx.roomId, ctx.eventId, emoji);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Gratitude — grant rep when the LLM detects genuine thanks
|
|
||||||
if (r.gratitude && r.gratitude_toward && r.gratitude_toward !== ctx.sender) {
|
|
||||||
const receiverId = r.gratitude_toward;
|
|
||||||
|
|
||||||
// Check 24h cooldown
|
|
||||||
const cooldown = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT granted_at FROM rep_cooldowns
|
|
||||||
WHERE giver_id = ? AND receiver_id = ? AND room_id = ?`
|
|
||||||
)
|
|
||||||
.get(ctx.sender, receiverId, ctx.roomId) as { granted_at: string } | undefined;
|
|
||||||
|
|
||||||
let onCooldown = false;
|
|
||||||
if (cooldown) {
|
|
||||||
const grantedAt = new Date(cooldown.granted_at + "Z").getTime();
|
|
||||||
onCooldown = Date.now() - grantedAt < REP_COOLDOWN_HOURS * 60 * 60 * 1000;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!onCooldown) {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO rep_cooldowns (giver_id, receiver_id, room_id, granted_at)
|
|
||||||
VALUES (?, ?, ?, datetime('now'))
|
|
||||||
ON CONFLICT(giver_id, receiver_id, room_id) DO UPDATE SET granted_at = datetime('now')`
|
|
||||||
).run(ctx.sender, receiverId, ctx.roomId);
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO users (user_id, room_id, rep)
|
|
||||||
VALUES (?, ?, 1)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET rep = rep + 1`
|
|
||||||
).run(receiverId, ctx.roomId);
|
|
||||||
|
|
||||||
this.xpPlugin.grantXp(receiverId, ctx.roomId, REP_XP_BONUS, "reputation");
|
|
||||||
|
|
||||||
this.sendReact(ctx.roomId, ctx.eventId, "\u2705");
|
|
||||||
|
|
||||||
logger.debug(`LLM: ${ctx.sender} gave rep to ${receiverId} in ${ctx.roomId}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sentiment reaction (only when no other reaction was sent)
|
|
||||||
if (!r.profanity && !r.insult && !r.wotd_used && r.sentiment !== "neutral") {
|
|
||||||
const sentimentEmojis: Record<Sentiment, string[]> = {
|
|
||||||
neutral: [],
|
|
||||||
sad: ["\uD83E\uDEE2", "\uD83D\uDE22", "\uD83E\uDD79", "\uD83D\uDC94", "\uD83E\uDD17"], // 🫢 😢 🥹 💔 🤗
|
|
||||||
happy: ["\uD83D\uDE04", "\uD83C\uDF89", "\uD83D\uDE0A", "\u2728", "\uD83D\uDC4F"], // 😄 🎉 😊 ✨ 👏
|
|
||||||
angry: ["\uD83D\uDE24", "\uD83D\uDCA2", "\uD83D\uDE21"], // 😤 💢 😡
|
|
||||||
excited: ["\uD83D\uDE06", "\uD83D\uDD25", "\uD83C\uDF89", "\uD83D\uDE4C", "\u26A1"], // 😆 🔥 🎉 🙌 ⚡
|
|
||||||
funny: ["\uD83D\uDE02", "\uD83D\uDC80", "\uD83E\uDD23", "\uD83D\uDE06"], // 😂 💀 🤣 😆
|
|
||||||
love: ["\u2764\uFE0F", "\uD83E\uDD70", "\uD83D\uDE0D", "\uD83D\uDC96", "\uD83D\uDC9E"], // ❤️ 🥰 😍 💖 💞
|
|
||||||
scared: ["\uD83D\uDE28", "\uD83D\uDE31", "\uD83D\uDE30"], // 😨 😱 😰
|
|
||||||
};
|
|
||||||
|
|
||||||
const choices = sentimentEmojis[r.sentiment];
|
|
||||||
if (choices.length > 0) {
|
|
||||||
const pick = choices[Math.floor(Math.random() * choices.length)];
|
|
||||||
this.sendReact(ctx.roomId, ctx.eventId, pick);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Commands ---
|
|
||||||
|
|
||||||
private async handlePotty(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "potty");
|
|
||||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT total, severity_1, severity_2, severity_3 FROM potty_mouth WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { total: number; severity_1: number; severity_2: number; severity_3: number } | undefined;
|
|
||||||
|
|
||||||
if (!row || row.total === 0) {
|
|
||||||
await this.sendMessage(ctx.roomId, `${targetUser} has a clean mouth. For now.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(
|
|
||||||
ctx.roomId,
|
|
||||||
`${targetUser} potty mouth: Total: ${row.total} (mild: ${row.severity_1}, moderate: ${row.severity_2}, scorched: ${row.severity_3})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handlePottyboard(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT user_id, total, severity_1, severity_2, severity_3 FROM potty_mouth WHERE room_id = ? ORDER BY total DESC LIMIT 10`)
|
|
||||||
.all(ctx.roomId) as { user_id: string; total: number; severity_1: number; severity_2: number; severity_3: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No potty mouth data yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map(
|
|
||||||
(r, i) => `${i + 1}. ${r.user_id} — ${r.total} (mild: ${r.severity_1}, moderate: ${r.severity_2}, scorched: ${r.severity_3})`
|
|
||||||
);
|
|
||||||
await this.sendMessage(ctx.roomId, `Potty Mouth Leaderboard:\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleInsults(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "insults");
|
|
||||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT total_thrown, direct_thrown, indirect_thrown, times_targeted FROM insult_log WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { total_thrown: number; direct_thrown: number; indirect_thrown: number; times_targeted: number } | undefined;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendMessage(ctx.roomId, `${targetUser} has no insult data. A saint, apparently.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(
|
|
||||||
ctx.roomId,
|
|
||||||
`${targetUser} insults: Thrown: ${row.total_thrown} (direct: ${row.direct_thrown}, indirect: ${row.indirect_thrown}) | Received: ${row.times_targeted} times`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleInsultboard(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT user_id, total_thrown, direct_thrown, indirect_thrown FROM insult_log WHERE room_id = ? ORDER BY total_thrown DESC LIMIT 10`)
|
|
||||||
.all(ctx.roomId) as { user_id: string; total_thrown: number; direct_thrown: number; indirect_thrown: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No insult data yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map(
|
|
||||||
(r, i) => `${i + 1}. ${r.user_id} — ${r.total_thrown} thrown (direct: ${r.direct_thrown}, indirect: ${r.indirect_thrown})`
|
|
||||||
);
|
|
||||||
await this.sendMessage(ctx.roomId, `Insult Leaderboard:\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWotdAttempts(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT user_id, xp_awarded FROM wotd_usage WHERE room_id = ? AND date = ?`)
|
|
||||||
.all(ctx.roomId, today) as { user_id: string; xp_awarded: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No one has attempted today's WOTD yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map(
|
|
||||||
(r) => `${r.user_id} — ${r.xp_awarded > 0 ? "credited" : "no credit"}`
|
|
||||||
);
|
|
||||||
await this.sendMessage(ctx.roomId, `Today's WOTD attempts:\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,242 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
import { RateLimitsPlugin } from "./ratelimits";
|
|
||||||
|
|
||||||
const LANG_CODES = ["pt", "es", "fr", "de", "ja", "ko", "zh", "ar", "ru", "it"];
|
|
||||||
|
|
||||||
export class LookupPlugin extends Plugin {
|
|
||||||
private rateLimitPlugin: RateLimitsPlugin;
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient, rateLimitPlugin: RateLimitsPlugin) {
|
|
||||||
super(client);
|
|
||||||
this.rateLimitPlugin = rateLimitPlugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "lookup";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "wiki", description: "Look up a Wikipedia article", usage: "!wiki <topic>" },
|
|
||||||
{ name: "define", description: "Look up a word definition", usage: "!define <word>" },
|
|
||||||
{ name: "translate", description: "Translate text using LibreTranslate", usage: "!translate [lang] <text>" },
|
|
||||||
{ name: "urban", description: "Urban Dictionary lookup", usage: "!urban <term>" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "wiki")) {
|
|
||||||
await this.handleWiki(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "define")) {
|
|
||||||
await this.handleDefine(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "translate")) {
|
|
||||||
await this.handleTranslate(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "urban")) {
|
|
||||||
await this.handleUrban(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWiki(ctx: MessageContext): Promise<void> {
|
|
||||||
const topic = this.getArgs(ctx.body, "wiki");
|
|
||||||
if (!topic) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !wiki <topic>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(topic)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
|
|
||||||
if (res.status === 404) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No Wikipedia article found for that topic.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch Wikipedia article.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const title = data.title ?? topic;
|
|
||||||
let extract = data.extract ?? "";
|
|
||||||
if (extract.length > 500) {
|
|
||||||
extract = extract.slice(0, 500) + "...";
|
|
||||||
}
|
|
||||||
const pageUrl = data.content_urls?.desktop?.page ?? "";
|
|
||||||
|
|
||||||
const reply = `${title}\n\n${extract}\n\nRead more: ${pageUrl}`;
|
|
||||||
await this.sendMessage(ctx.roomId, reply);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Wiki lookup failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while looking up that topic.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleDefine(ctx: MessageContext): Promise<void> {
|
|
||||||
const word = this.getArgs(ctx.body, "define");
|
|
||||||
if (!word) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !define <word>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `https://api.dictionaryapi.dev/api/v2/entries/en/${encodeURIComponent(word)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No definition found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any[];
|
|
||||||
if (!data || data.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No definition found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entry = data[0];
|
|
||||||
const entryWord = entry.word ?? word;
|
|
||||||
const phonetic = entry.phonetic ?? "";
|
|
||||||
const meaning = entry.meanings?.[0];
|
|
||||||
const partOfSpeech = meaning?.partOfSpeech ?? "";
|
|
||||||
const definition = meaning?.definitions?.[0]?.definition ?? "";
|
|
||||||
const example = meaning?.definitions?.[0]?.example;
|
|
||||||
|
|
||||||
let reply = `${entryWord}${phonetic ? ` (${phonetic})` : ""}\n[${partOfSpeech}] ${definition}`;
|
|
||||||
if (example) {
|
|
||||||
reply += `\nExample: "${example}"`;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, reply);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Define lookup failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while looking up that word.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleUrban(ctx: MessageContext): Promise<void> {
|
|
||||||
const term = this.getArgs(ctx.body, "urban");
|
|
||||||
if (!term) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !urban <term>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const termLower = term.toLowerCase();
|
|
||||||
const CACHE_TTL = 24 * 60 * 60; // 24 hours
|
|
||||||
|
|
||||||
// Check cache
|
|
||||||
const cached = db.prepare(
|
|
||||||
`SELECT data FROM urban_cache WHERE term = ? AND cached_at > unixepoch() - ?`
|
|
||||||
).get(termLower, CACHE_TTL) as { data: string } | undefined;
|
|
||||||
|
|
||||||
let entry: any;
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
entry = JSON.parse(cached.data);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
const url = `https://api.urbandictionary.com/v0/define?term=${encodeURIComponent(term)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to look up that term.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { list: any[] };
|
|
||||||
if (!data.list || data.list.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No Urban Dictionary entry found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
entry = data.list[0];
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO urban_cache (term, data) VALUES (?, ?)
|
|
||||||
ON CONFLICT(term) DO UPDATE SET data = excluded.data, cached_at = unixepoch()`
|
|
||||||
).run(termLower, JSON.stringify(entry));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Urban Dictionary lookup failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while looking up that term.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const word = entry.word ?? term;
|
|
||||||
let definition = (entry.definition ?? "").replace(/\[|\]/g, "");
|
|
||||||
if (definition.length > 400) definition = definition.slice(0, 400) + "...";
|
|
||||||
let example = (entry.example ?? "").replace(/\[|\]/g, "");
|
|
||||||
if (example.length > 200) example = example.slice(0, 200) + "...";
|
|
||||||
const thumbsUp = entry.thumbs_up ?? 0;
|
|
||||||
const thumbsDown = entry.thumbs_down ?? 0;
|
|
||||||
|
|
||||||
let reply = `${word}\n\n${definition}`;
|
|
||||||
if (example) reply += `\n\nExample: "${example}"`;
|
|
||||||
reply += `\n\n\uD83D\uDC4D ${thumbsUp} \uD83D\uDC4E ${thumbsDown}`;
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTranslate(ctx: MessageContext): Promise<void> {
|
|
||||||
const LIBRETRANSLATE_URL = process.env.LIBRETRANSLATE_URL;
|
|
||||||
if (!LIBRETRANSLATE_URL) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "LibreTranslate is not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "translate");
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !translate [lang] <text>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = this.rateLimitPlugin.checkLimit(
|
|
||||||
ctx.sender,
|
|
||||||
"translate",
|
|
||||||
parseInt(process.env.RATELIMIT_TRANSLATE ?? "20"),
|
|
||||||
);
|
|
||||||
if (!allowed) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "You have reached your daily translate quota. Try again tomorrow.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let targetLang = "en";
|
|
||||||
let text = args;
|
|
||||||
|
|
||||||
const words = args.split(/\s+/);
|
|
||||||
if (words.length > 1 && words[0].length === 2 && LANG_CODES.includes(words[0].toLowerCase())) {
|
|
||||||
targetLang = words[0].toLowerCase();
|
|
||||||
text = words.slice(1).join(" ");
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${LIBRETRANSLATE_URL}/translate`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
q: text,
|
|
||||||
source: "auto",
|
|
||||||
target: targetLang,
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Translation request failed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const translatedText = data.translatedText ?? "";
|
|
||||||
const detectedLang = data.detectedLanguage?.language ?? "auto";
|
|
||||||
|
|
||||||
const reply = `[${detectedLang} \u2192 ${targetLang}] ${translatedText}`;
|
|
||||||
await this.sendMessage(ctx.roomId, reply);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Translate failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while translating.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,140 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
export class MarkovPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "markov";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "markov", description: "Generate a Markov chain sentence", usage: "!markov [@user|me]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
// Passive: collect corpus from non-command messages
|
|
||||||
if (!ctx.body.startsWith(this.prefix)) {
|
|
||||||
this.recordMessage(ctx);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "markov")) {
|
|
||||||
await this.handleMarkov(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private recordMessage(ctx: MessageContext): void {
|
|
||||||
const db = getDb();
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO markov_corpus (user_id, room_id, message, added_at) VALUES (?, ?, ?, ?)`
|
|
||||||
).run(ctx.sender, ctx.roomId, ctx.body, now);
|
|
||||||
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT COUNT(*) as cnt FROM markov_corpus WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(ctx.sender, ctx.roomId) as { cnt: number };
|
|
||||||
|
|
||||||
if (row.cnt > 10000) {
|
|
||||||
db.prepare(
|
|
||||||
`DELETE FROM markov_corpus WHERE id IN (
|
|
||||||
SELECT id FROM markov_corpus WHERE user_id = ? AND room_id = ? ORDER BY id ASC LIMIT 1000
|
|
||||||
)`
|
|
||||||
).run(ctx.sender, ctx.roomId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMarkov(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "markov");
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
let messages: { message: string }[];
|
|
||||||
let label: string;
|
|
||||||
|
|
||||||
if (!args) {
|
|
||||||
// Room-wide combined corpus
|
|
||||||
messages = db
|
|
||||||
.prepare(`SELECT message FROM markov_corpus WHERE room_id = ?`)
|
|
||||||
.all(ctx.roomId) as { message: string }[];
|
|
||||||
label = "room";
|
|
||||||
} else if (args === "me") {
|
|
||||||
messages = db
|
|
||||||
.prepare(`SELECT message FROM markov_corpus WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.all(ctx.sender, ctx.roomId) as { message: string }[];
|
|
||||||
label = "user";
|
|
||||||
} else if (args.startsWith("@")) {
|
|
||||||
const targetUser = args.split(/\s/)[0];
|
|
||||||
messages = db
|
|
||||||
.prepare(`SELECT message FROM markov_corpus WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.all(targetUser, ctx.roomId) as { message: string }[];
|
|
||||||
label = "user";
|
|
||||||
} else {
|
|
||||||
// Treat unknown arg as a user mention without @
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (messages.length < 50) {
|
|
||||||
const reply =
|
|
||||||
label === "room"
|
|
||||||
? "Not enough data in this room yet."
|
|
||||||
: `Not enough data to impersonate ${args === "me" ? ctx.sender : args.split(/\s/)[0]} yet.`;
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, reply);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sentence = this.generateSentence(messages);
|
|
||||||
if (!sentence) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't generate anything. Try again later.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, sentence);
|
|
||||||
}
|
|
||||||
|
|
||||||
private generateSentence(messages: { message: string }[]): string | null {
|
|
||||||
const transitions = new Map<string, string[]>();
|
|
||||||
const starters: string[] = [];
|
|
||||||
|
|
||||||
for (const { message } of messages) {
|
|
||||||
const words = message.split(/\s+/).filter((w) => w.length > 0);
|
|
||||||
if (words.length < 3) continue;
|
|
||||||
|
|
||||||
const starterKey = `${words[0]} ${words[1]}`;
|
|
||||||
starters.push(starterKey);
|
|
||||||
|
|
||||||
for (let i = 0; i < words.length - 2; i++) {
|
|
||||||
const key = `${words[i]} ${words[i + 1]}`;
|
|
||||||
const next = words[i + 2];
|
|
||||||
let list = transitions.get(key);
|
|
||||||
if (!list) {
|
|
||||||
list = [];
|
|
||||||
transitions.set(key, list);
|
|
||||||
}
|
|
||||||
list.push(next);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (starters.length === 0) return null;
|
|
||||||
|
|
||||||
const startKey = starters[Math.floor(Math.random() * starters.length)];
|
|
||||||
const result = startKey.split(" ");
|
|
||||||
|
|
||||||
for (let i = 0; i < 48; i++) {
|
|
||||||
const key = `${result[result.length - 2]} ${result[result.length - 1]}`;
|
|
||||||
const candidates = transitions.get(key);
|
|
||||||
if (!candidates || candidates.length === 0) break;
|
|
||||||
|
|
||||||
const next = candidates[Math.floor(Math.random() * candidates.length)];
|
|
||||||
result.push(next);
|
|
||||||
|
|
||||||
if (/[.!?]$/.test(next)) break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.join(" ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,451 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const CACHE_TTL = 24 * 60 * 60; // 24 hours
|
|
||||||
|
|
||||||
export class MoviesPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "movies";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "movie", description: "Search movies or manage watchlist", usage: "!movie <title> | !movie watch <title> | !movie watching | !movie unwatch <title>" },
|
|
||||||
{ name: "tv", description: "Search TV shows or add to watchlist", usage: "!tv <title> | !tv watch <title>" },
|
|
||||||
{ name: "upcoming", description: "Upcoming theatrical releases", usage: "!upcoming movies [week|month]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private get apiKey(): string | undefined {
|
|
||||||
return process.env.TMDB_API_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "upcoming movies")) {
|
|
||||||
await this.handleUpcoming(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "movie")) {
|
|
||||||
await this.handleMovie(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "tv")) {
|
|
||||||
await this.handleTv(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Movie command ---
|
|
||||||
|
|
||||||
private async handleMovie(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "TMDB API key not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "movie");
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !movie <title> | !movie watch <title> | !movie watching | !movie unwatch <title>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args === "watching") {
|
|
||||||
await this.handleWatching(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.startsWith("unwatch ")) {
|
|
||||||
const query = args.slice(8).trim();
|
|
||||||
if (!query) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !movie unwatch <title>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.handleUnwatch(ctx, query);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.startsWith("watch ")) {
|
|
||||||
const title = args.slice(6).trim();
|
|
||||||
if (!title) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !movie watch <title>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.handleWatch(ctx, title, "movie");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.handleMovieSearch(ctx, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TV command ---
|
|
||||||
|
|
||||||
private async handleTv(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "TMDB API key not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "tv");
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !tv <title> | !tv watch <title>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.startsWith("watch ")) {
|
|
||||||
const title = args.slice(6).trim();
|
|
||||||
if (!title) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !tv watch <title>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.handleWatch(ctx, title, "tv");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.handleTvSearch(ctx, args);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Movie search ---
|
|
||||||
|
|
||||||
private async handleMovieSearch(ctx: MessageContext, title: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const url = `https://api.themoviedb.org/3/search/movie?api_key=${this.apiKey}&query=${encodeURIComponent(title)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to search TMDB.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const results = data.results ?? [];
|
|
||||||
if (results.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No movies found for "${title}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const movie = results[0];
|
|
||||||
const details = await this.fetchDetails(movie.id, "movie");
|
|
||||||
if (!details) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch movie details.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = details.release_date ? details.release_date.slice(0, 4) : "N/A";
|
|
||||||
const rating = details.vote_average != null ? details.vote_average.toFixed(1) : "N/A";
|
|
||||||
const runtime = details.runtime ?? "N/A";
|
|
||||||
const genres = (details.genres ?? []).map((g: any) => g.name).join(", ") || "N/A";
|
|
||||||
const overview = details.overview
|
|
||||||
? details.overview.length > 300
|
|
||||||
? details.overview.slice(0, 300) + "..."
|
|
||||||
: details.overview
|
|
||||||
: "No overview available.";
|
|
||||||
const releaseDate = details.release_date ?? "N/A";
|
|
||||||
|
|
||||||
const output = [
|
|
||||||
`${details.title} (${year}) — ${rating}/10 | ${runtime} min | ${genres}`,
|
|
||||||
overview,
|
|
||||||
`Release: ${releaseDate}`,
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, output);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Movie search failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while searching.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TV search ---
|
|
||||||
|
|
||||||
private async handleTvSearch(ctx: MessageContext, title: string): Promise<void> {
|
|
||||||
try {
|
|
||||||
const url = `https://api.themoviedb.org/3/search/tv?api_key=${this.apiKey}&query=${encodeURIComponent(title)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to search TMDB.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const results = data.results ?? [];
|
|
||||||
if (results.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No TV shows found for "${title}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const show = results[0];
|
|
||||||
const details = await this.fetchDetails(show.id, "tv");
|
|
||||||
if (!details) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch TV details.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const year = details.first_air_date ? details.first_air_date.slice(0, 4) : "N/A";
|
|
||||||
const rating = details.vote_average != null ? details.vote_average.toFixed(1) : "N/A";
|
|
||||||
const seasons = details.number_of_seasons ?? "N/A";
|
|
||||||
const status = details.status ?? "N/A";
|
|
||||||
const genres = (details.genres ?? []).map((g: any) => g.name).join(", ") || "N/A";
|
|
||||||
const overview = details.overview
|
|
||||||
? details.overview.length > 300
|
|
||||||
? details.overview.slice(0, 300) + "..."
|
|
||||||
: details.overview
|
|
||||||
: "No overview available.";
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
`${details.name} (${year}) — ${rating}/10 | Seasons: ${seasons} | ${status}`,
|
|
||||||
overview,
|
|
||||||
];
|
|
||||||
|
|
||||||
if (details.next_episode_to_air && details.next_episode_to_air.air_date) {
|
|
||||||
lines.push(`Next episode: ${details.next_episode_to_air.air_date}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`TV search failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while searching.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Watch (add to watchlist) ---
|
|
||||||
|
|
||||||
private async handleWatch(ctx: MessageContext, title: string, mediaType: "movie" | "tv"): Promise<void> {
|
|
||||||
try {
|
|
||||||
const searchType = mediaType === "movie" ? "movie" : "tv";
|
|
||||||
const url = `https://api.themoviedb.org/3/search/${searchType}?api_key=${this.apiKey}&query=${encodeURIComponent(title)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to search TMDB.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const results = data.results ?? [];
|
|
||||||
if (results.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No ${mediaType === "movie" ? "movies" : "TV shows"} found for "${title}".`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const item = results[0];
|
|
||||||
const tmdbId = item.id;
|
|
||||||
const itemTitle = mediaType === "movie" ? item.title : item.name;
|
|
||||||
const releaseDate = mediaType === "movie" ? (item.release_date ?? null) : (item.first_air_date ?? null);
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO movie_watchlist (user_id, room_id, tmdb_id, title, media_type, release_date, created_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?, unixepoch())
|
|
||||||
`).run(ctx.sender, ctx.roomId, tmdbId, itemTitle, mediaType, releaseDate);
|
|
||||||
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Added "${itemTitle}" (${mediaType}) to your watchlist.`);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err?.code === "SQLITE_CONSTRAINT") {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "That title is already on your watchlist.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
logger.error(`Watch add failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while adding to watchlist.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Watching (list watchlist) ---
|
|
||||||
|
|
||||||
private async handleWatching(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT title, media_type, release_date FROM movie_watchlist WHERE user_id = ? AND room_id = ? ORDER BY media_type ASC, title ASC`)
|
|
||||||
.all(ctx.sender, ctx.roomId) as { title: string; media_type: string; release_date: string | null }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your watchlist is empty.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const movies = rows.filter((r) => r.media_type === "movie");
|
|
||||||
const tvShows = rows.filter((r) => r.media_type === "tv");
|
|
||||||
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
if (movies.length > 0) {
|
|
||||||
lines.push("Movies:");
|
|
||||||
for (const m of movies) {
|
|
||||||
lines.push(` - ${m.title}${m.release_date ? ` (${m.release_date})` : ""}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tvShows.length > 0) {
|
|
||||||
lines.push("TV Shows:");
|
|
||||||
for (const t of tvShows) {
|
|
||||||
lines.push(` - ${t.title}${t.release_date ? ` (${t.release_date})` : ""}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Unwatch ---
|
|
||||||
|
|
||||||
private async handleUnwatch(ctx: MessageContext, query: string): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT id, title FROM movie_watchlist WHERE user_id = ? AND room_id = ? AND title LIKE ? LIMIT 1`)
|
|
||||||
.get(ctx.sender, ctx.roomId, `%${query}%`) as { id: number; title: string } | undefined;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No watchlist entry matching "${query}" found.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare(`DELETE FROM movie_watchlist WHERE id = ?`).run(row.id);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Removed "${row.title}" from your watchlist.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Upcoming movies ---
|
|
||||||
|
|
||||||
private async handleUpcoming(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "TMDB API key not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "upcoming movies").trim().toLowerCase();
|
|
||||||
const days = args === "week" ? 7 : 30;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `https://api.themoviedb.org/3/movie/upcoming?api_key=${this.apiKey}®ion=US`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch upcoming movies.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
const movies = data.results ?? [];
|
|
||||||
|
|
||||||
const now = new Date();
|
|
||||||
const cutoff = new Date(now.getTime() + days * 24 * 60 * 60 * 1000);
|
|
||||||
const todayStr = now.toISOString().slice(0, 10);
|
|
||||||
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const upcoming = movies.filter((m: any) => {
|
|
||||||
if (!m.release_date) return false;
|
|
||||||
return m.release_date >= todayStr && m.release_date <= cutoffStr;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (upcoming.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No upcoming theatrical releases in the next ${days} days.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [`Upcoming Movies (next ${days} days):`];
|
|
||||||
for (const m of upcoming) {
|
|
||||||
lines.push(`- ${m.title} (${m.release_date})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Upcoming movies fetch failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "An error occurred while fetching upcoming movies.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- TMDB details with cache ---
|
|
||||||
|
|
||||||
private async fetchDetails(tmdbId: number, mediaType: string): Promise<any | null> {
|
|
||||||
const cached = this.getCached(tmdbId, mediaType);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `https://api.themoviedb.org/3/${mediaType}/${tmdbId}?api_key=${this.apiKey}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`TMDB details API returned ${res.status} for ${mediaType}/${tmdbId}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
this.setCache(tmdbId, mediaType, data);
|
|
||||||
return data;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`TMDB details fetch failed for ${mediaType}/${tmdbId}: ${err}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCached(tmdbId: number, mediaType: string): any | null {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT data, cached_at FROM movie_cache WHERE tmdb_id = ? AND media_type = ?`)
|
|
||||||
.get(tmdbId, mediaType) as { data: string; cached_at: number } | undefined;
|
|
||||||
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
const age = Math.floor(Date.now() / 1000) - row.cached_at;
|
|
||||||
if (age > CACHE_TTL) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(row.data);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setCache(tmdbId: number, mediaType: string, data: any): void {
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`INSERT OR REPLACE INTO movie_cache (tmdb_id, media_type, data, cached_at) VALUES (?, ?, ?, unixepoch())`)
|
|
||||||
.run(tmdbId, mediaType, JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Daily releases (called by scheduler at 20:00 UTC) ---
|
|
||||||
|
|
||||||
async postDailyReleases(botRooms: string[]): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
const releases = db
|
|
||||||
.prepare(`SELECT DISTINCT tmdb_id, title, media_type FROM movie_watchlist WHERE release_date = ? AND notified = 0`)
|
|
||||||
.all(today) as { tmdb_id: number; title: string; media_type: string }[];
|
|
||||||
|
|
||||||
if (releases.length === 0) return;
|
|
||||||
|
|
||||||
const lines = ["Today's Watchlist Releases:"];
|
|
||||||
for (const r of releases) {
|
|
||||||
const typeLabel = r.media_type === "movie" ? "Movie" : "TV";
|
|
||||||
lines.push(`- [${typeLabel}] ${r.title}`);
|
|
||||||
}
|
|
||||||
const message = lines.join("\n");
|
|
||||||
|
|
||||||
// Post to each bot room
|
|
||||||
for (const roomId of botRooms) {
|
|
||||||
try {
|
|
||||||
await this.sendMessage(roomId, message);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to post daily movie releases to ${roomId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// DM users who have these on their watchlist
|
|
||||||
const userEntries = db
|
|
||||||
.prepare(`SELECT id, user_id, title, media_type FROM movie_watchlist WHERE release_date = ? AND notified = 0`)
|
|
||||||
.all(today) as { id: number; user_id: string; title: string; media_type: string }[];
|
|
||||||
|
|
||||||
const userMap = new Map<string, string[]>();
|
|
||||||
for (const entry of userEntries) {
|
|
||||||
const typeLabel = entry.media_type === "movie" ? "Movie" : "TV";
|
|
||||||
const label = `[${typeLabel}] ${entry.title}`;
|
|
||||||
const existing = userMap.get(entry.user_id) ?? [];
|
|
||||||
existing.push(label);
|
|
||||||
userMap.set(entry.user_id, existing);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [userId, titles] of userMap) {
|
|
||||||
try {
|
|
||||||
const dmMessage = `Your watchlist releases today:\n${titles.map((t) => `- ${t}`).join("\n")}`;
|
|
||||||
await this.sendDm(userId, dmMessage);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to DM movie releases to ${userId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Mark all as notified
|
|
||||||
db.prepare(`UPDATE movie_watchlist SET notified = 1 WHERE release_date = ? AND notified = 0`).run(today);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,192 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import { DateTime, IANAZone } from "luxon";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const STATUS_EMOJI: Record<string, string> = {
|
|
||||||
online: "\u{1F7E2}",
|
|
||||||
away: "\u{1F7E1}",
|
|
||||||
afk: "\u{1F534}",
|
|
||||||
};
|
|
||||||
|
|
||||||
export class PresencePlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "presence";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "away", description: "Set status to away", usage: "!away [message]" },
|
|
||||||
{ name: "afk", description: "Set status to AFK", usage: "!afk [message]" },
|
|
||||||
{ name: "back", description: "Set status back to online", usage: "!back" },
|
|
||||||
{ name: "whois", description: "Show user profile card", usage: "!whois @user" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Passive: auto-clear away/afk on any message
|
|
||||||
db.prepare(
|
|
||||||
`UPDATE presence SET status = 'online', status_message = NULL, updated_at = unixepoch()
|
|
||||||
WHERE user_id = ? AND room_id = ? AND status IN ('away', 'afk')`
|
|
||||||
).run(ctx.sender, ctx.roomId);
|
|
||||||
|
|
||||||
// Passive: always upsert updated_at for last seen
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO presence (user_id, room_id, status, updated_at)
|
|
||||||
VALUES (?, ?, 'online', unixepoch())
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET updated_at = unixepoch()`
|
|
||||||
).run(ctx.sender, ctx.roomId);
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "away")) {
|
|
||||||
await this.handleAway(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "afk")) {
|
|
||||||
await this.handleAfk(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "back")) {
|
|
||||||
await this.handleBack(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "whois")) {
|
|
||||||
await this.handleWhois(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleAway(ctx: MessageContext): Promise<void> {
|
|
||||||
const message = this.getArgs(ctx.body, "away") || null;
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO presence (user_id, room_id, status, status_message, updated_at)
|
|
||||||
VALUES (?, ?, 'away', ?, unixepoch())
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET status = 'away', status_message = excluded.status_message, updated_at = unixepoch()`
|
|
||||||
).run(ctx.sender, ctx.roomId, message);
|
|
||||||
|
|
||||||
const reply = message
|
|
||||||
? `${ctx.sender} is now away: ${message}`
|
|
||||||
: `${ctx.sender} is now away.`;
|
|
||||||
await this.sendMessage(ctx.roomId, reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleAfk(ctx: MessageContext): Promise<void> {
|
|
||||||
const message = this.getArgs(ctx.body, "afk") || null;
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO presence (user_id, room_id, status, status_message, updated_at)
|
|
||||||
VALUES (?, ?, 'afk', ?, unixepoch())
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET status = 'afk', status_message = excluded.status_message, updated_at = unixepoch()`
|
|
||||||
).run(ctx.sender, ctx.roomId, message);
|
|
||||||
|
|
||||||
const reply = message
|
|
||||||
? `${ctx.sender} is now AFK: ${message}`
|
|
||||||
: `${ctx.sender} is now AFK.`;
|
|
||||||
await this.sendMessage(ctx.roomId, reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleBack(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO presence (user_id, room_id, status, status_message, updated_at)
|
|
||||||
VALUES (?, ?, 'online', NULL, unixepoch())
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET status = 'online', status_message = NULL, updated_at = unixepoch()`
|
|
||||||
).run(ctx.sender, ctx.roomId);
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, `${ctx.sender} is back online.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWhois(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "whois").trim();
|
|
||||||
const targetUser = args.split(/\s/)[0];
|
|
||||||
|
|
||||||
if (!targetUser || !targetUser.startsWith("@")) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !whois @user:server");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Presence data
|
|
||||||
const presence = db
|
|
||||||
.prepare(`SELECT status, status_message, updated_at FROM presence WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { status: string; status_message: string | null; updated_at: number } | undefined;
|
|
||||||
|
|
||||||
const status = presence?.status ?? "online";
|
|
||||||
const statusMessage = presence?.status_message;
|
|
||||||
const updatedAt = presence?.updated_at;
|
|
||||||
|
|
||||||
// User data
|
|
||||||
const user = db
|
|
||||||
.prepare(`SELECT xp, level, rep, timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { xp: number; level: number; rep: number; timezone: string | null } | undefined;
|
|
||||||
|
|
||||||
// Streak data
|
|
||||||
const stats = db
|
|
||||||
.prepare(`SELECT current_streak FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { current_streak: number } | undefined;
|
|
||||||
|
|
||||||
// Now playing
|
|
||||||
const playing = db
|
|
||||||
.prepare(`SELECT game FROM now_playing WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { game: string } | undefined;
|
|
||||||
|
|
||||||
// Build profile card
|
|
||||||
const lines: string[] = [targetUser];
|
|
||||||
|
|
||||||
// Status line
|
|
||||||
const emoji = STATUS_EMOJI[status] ?? STATUS_EMOJI.online;
|
|
||||||
const statusLabel = status.charAt(0).toUpperCase() + status.slice(1);
|
|
||||||
let statusLine = `Status: ${emoji} ${statusLabel}`;
|
|
||||||
if (statusMessage) statusLine += ` (${statusMessage})`;
|
|
||||||
lines.push(statusLine);
|
|
||||||
|
|
||||||
// Last seen
|
|
||||||
if (updatedAt) {
|
|
||||||
lines.push(`Last seen: ${this.formatLastSeen(updatedAt)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Timezone
|
|
||||||
if (user?.timezone) {
|
|
||||||
const zone = IANAZone.create(user.timezone);
|
|
||||||
if (zone.isValid) {
|
|
||||||
const localTime = DateTime.now().setZone(zone).toFormat("HH:mm");
|
|
||||||
lines.push(`Timezone: ${user.timezone} (${localTime} local)`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now playing
|
|
||||||
if (playing?.game) {
|
|
||||||
lines.push(`Now playing: ${playing.game}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rep & Level
|
|
||||||
const rep = user?.rep ?? 0;
|
|
||||||
const level = user?.level ?? 0;
|
|
||||||
lines.push(`Reputation: ${rep} | Level: ${level}`);
|
|
||||||
|
|
||||||
// Streak
|
|
||||||
const streak = stats?.current_streak ?? 0;
|
|
||||||
lines.push(`Streak: ${streak} days`);
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatLastSeen(updatedAt: number): string {
|
|
||||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
|
||||||
const diffSeconds = nowSeconds - updatedAt;
|
|
||||||
|
|
||||||
if (diffSeconds < 60) return "just now";
|
|
||||||
|
|
||||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
|
||||||
if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes !== 1 ? "s" : ""} ago`;
|
|
||||||
|
|
||||||
const diffHours = Math.floor(diffMinutes / 60);
|
|
||||||
if (diffHours < 24) return `${diffHours} hour${diffHours !== 1 ? "s" : ""} ago`;
|
|
||||||
|
|
||||||
const diffDays = Math.floor(diffHours / 24);
|
|
||||||
return `${diffDays} day${diffDays !== 1 ? "s" : ""} ago`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
|
|
||||||
export class RateLimitsPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
this.ensureTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "ratelimits";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(_ctx: MessageContext): Promise<void> {
|
|
||||||
// No-op: this plugin provides utility methods only
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureTable(): void {
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`
|
|
||||||
CREATE TABLE IF NOT EXISTS command_usage (
|
|
||||||
user_id TEXT,
|
|
||||||
command TEXT,
|
|
||||||
date TEXT,
|
|
||||||
count INTEGER DEFAULT 0,
|
|
||||||
PRIMARY KEY (user_id, command, date)
|
|
||||||
)
|
|
||||||
`).run();
|
|
||||||
}
|
|
||||||
|
|
||||||
private todayKey(): string {
|
|
||||||
const now = new Date();
|
|
||||||
const y = now.getUTCFullYear();
|
|
||||||
const m = String(now.getUTCMonth() + 1).padStart(2, "0");
|
|
||||||
const d = String(now.getUTCDate()).padStart(2, "0");
|
|
||||||
return `${y}-${m}-${d}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCount(userId: string, command: string, date: string): number {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT count FROM command_usage WHERE user_id = ? AND command = ? AND date = ?`)
|
|
||||||
.get(userId, command, date) as { count: number } | undefined;
|
|
||||||
return row?.count ?? 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
checkLimit(userId: string, command: string, dailyMax: number): boolean {
|
|
||||||
if (dailyMax === 0) return true;
|
|
||||||
if (this.isAdmin(userId)) return true;
|
|
||||||
|
|
||||||
const date = this.todayKey();
|
|
||||||
const current = this.getCount(userId, command, date);
|
|
||||||
|
|
||||||
if (current >= dailyMax) return false;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO command_usage (user_id, command, date, count)
|
|
||||||
VALUES (?, ?, ?, 1)
|
|
||||||
ON CONFLICT(user_id, command, date) DO UPDATE SET count = count + 1
|
|
||||||
`).run(userId, command, date);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
remaining(userId: string, command: string, dailyMax: number): number {
|
|
||||||
if (dailyMax === 0) return Infinity;
|
|
||||||
if (this.isAdmin(userId)) return Infinity;
|
|
||||||
|
|
||||||
const date = this.todayKey();
|
|
||||||
const current = this.getCount(userId, command, date);
|
|
||||||
return Math.max(0, dailyMax - current);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext, ReactionContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
export class ReactionsPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "reactions";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "emojiboard", description: "Reaction leaderboard" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "emojiboard")) {
|
|
||||||
await this.handleEmojiboard(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onReaction(ctx: ReactionContext): Promise<void> {
|
|
||||||
if (!ctx.reactionKey || !ctx.targetEventId) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const targetEvent = await this.client.getEvent(ctx.roomId, ctx.targetEventId);
|
|
||||||
const receiverId: string = targetEvent?.sender;
|
|
||||||
if (!receiverId || receiverId === ctx.sender) return; // Don't track self-reactions
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO reaction_log (giver_id, receiver_id, room_id, emoji, event_id)
|
|
||||||
VALUES (?, ?, ?, ?, ?)`
|
|
||||||
).run(ctx.sender, receiverId, ctx.roomId, ctx.reactionKey, ctx.eventId);
|
|
||||||
} catch {
|
|
||||||
// Event may not be found (deleted, redacted, or not yet decrypted) — silently skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleEmojiboard(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const topGivers = db.prepare(
|
|
||||||
`SELECT giver_id, COUNT(*) as count FROM reaction_log
|
|
||||||
WHERE room_id = ? GROUP BY giver_id ORDER BY count DESC LIMIT 5`
|
|
||||||
).all(ctx.roomId) as { giver_id: string; count: number }[];
|
|
||||||
|
|
||||||
const topReceivers = db.prepare(
|
|
||||||
`SELECT receiver_id, COUNT(*) as count FROM reaction_log
|
|
||||||
WHERE room_id = ? GROUP BY receiver_id ORDER BY count DESC LIMIT 5`
|
|
||||||
).all(ctx.roomId) as { receiver_id: string; count: number }[];
|
|
||||||
|
|
||||||
const topEmojis = db.prepare(
|
|
||||||
`SELECT emoji, COUNT(*) as count FROM reaction_log
|
|
||||||
WHERE room_id = ? GROUP BY emoji ORDER BY count DESC LIMIT 5`
|
|
||||||
).all(ctx.roomId) as { emoji: string; count: number }[];
|
|
||||||
|
|
||||||
if (topGivers.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No reaction data yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const giverLines = topGivers.map((r, i) => `${i + 1}. ${r.giver_id} — ${r.count}`);
|
|
||||||
const receiverLines = topReceivers.map((r, i) => `${i + 1}. ${r.receiver_id} — ${r.count}`);
|
|
||||||
const emojiLines = topEmojis.map((r, i) => `${i + 1}. ${r.emoji} — ${r.count}`);
|
|
||||||
|
|
||||||
const msg = [
|
|
||||||
"Reaction Leaderboard:",
|
|
||||||
"",
|
|
||||||
"Most Reactions Given:",
|
|
||||||
...giverLines,
|
|
||||||
"",
|
|
||||||
"Most Reactions Received:",
|
|
||||||
...receiverLines,
|
|
||||||
"",
|
|
||||||
"Most Used Emoji:",
|
|
||||||
...emojiLines,
|
|
||||||
].join("\n");
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, msg);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import * as chrono from "chrono-node";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
export class RemindersPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "reminders";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "remindme", description: "Set a reminder", usage: '!remindme <time> <message>' },
|
|
||||||
{ name: "reminders", description: "List your pending reminders" },
|
|
||||||
{ name: "unremind", description: "Cancel a reminder", usage: "!unremind <id>" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "remindme")) {
|
|
||||||
await this.handleRemindMe(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "unremind")) {
|
|
||||||
await this.handleUnremind(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "reminders")) {
|
|
||||||
await this.handleListReminders(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by DailyScheduler every 30 seconds to fire due reminders.
|
|
||||||
*/
|
|
||||||
async checkReminders(): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const due = db
|
|
||||||
.prepare(`SELECT * FROM reminders WHERE fired = 0 AND remind_at <= ?`)
|
|
||||||
.all(now) as {
|
|
||||||
id: string;
|
|
||||||
user_id: string;
|
|
||||||
room_id: string;
|
|
||||||
message: string;
|
|
||||||
remind_at: string;
|
|
||||||
}[];
|
|
||||||
|
|
||||||
for (const reminder of due) {
|
|
||||||
try {
|
|
||||||
await this.sendMessage(
|
|
||||||
reminder.room_id,
|
|
||||||
`Reminder for ${reminder.user_id}: ${reminder.message}`
|
|
||||||
);
|
|
||||||
db.prepare(`UPDATE reminders SET fired = 1 WHERE id = ?`).run(reminder.id);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to fire reminder ${reminder.id}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRemindMe(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "remindme");
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !remindme <time> <message>\nExample: !remindme in 2 hours check the oven");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get user timezone for parsing context
|
|
||||||
const db = getDb();
|
|
||||||
const userRow = db
|
|
||||||
.prepare(`SELECT timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(ctx.sender, ctx.roomId) as { timezone: string | null } | undefined;
|
|
||||||
|
|
||||||
const refDate = new Date();
|
|
||||||
const parsed = chrono.parse(args, refDate, { forwardDate: true });
|
|
||||||
|
|
||||||
if (parsed.length === 0 || !parsed[0].start) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "I couldn't understand that time. Try something like: in 30 minutes, tomorrow at 3pm, next friday");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remindAt = parsed[0].start.date();
|
|
||||||
// Extract message: everything after the parsed time expression
|
|
||||||
const timeText = parsed[0].text;
|
|
||||||
let message = args.slice(args.indexOf(timeText) + timeText.length).trim();
|
|
||||||
if (!message) message = "(no message)";
|
|
||||||
|
|
||||||
const id = uuidv4().slice(0, 8);
|
|
||||||
|
|
||||||
db.prepare(`INSERT INTO reminders (id, user_id, room_id, message, remind_at) VALUES (?, ?, ?, ?, ?)`).run(
|
|
||||||
id,
|
|
||||||
ctx.sender,
|
|
||||||
ctx.roomId,
|
|
||||||
message,
|
|
||||||
remindAt.toISOString()
|
|
||||||
);
|
|
||||||
|
|
||||||
const timeStr = remindAt.toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Reminder set for ${timeStr} (UTC). ID: ${id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleListReminders(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT id, message, remind_at FROM reminders WHERE user_id = ? AND room_id = ? AND fired = 0 ORDER BY remind_at ASC`)
|
|
||||||
.all(ctx.sender, ctx.roomId) as { id: string; message: string; remind_at: string }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "You have no pending reminders.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((r) => {
|
|
||||||
const time = new Date(r.remind_at).toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
|
|
||||||
return `[${r.id}] ${time} — ${r.message}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, `Your reminders:\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleUnremind(ctx: MessageContext): Promise<void> {
|
|
||||||
const id = this.getArgs(ctx.body, "unremind").trim();
|
|
||||||
if (!id) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !unremind <id>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const result = db
|
|
||||||
.prepare(`DELETE FROM reminders WHERE id = ? AND user_id = ? AND room_id = ? AND fired = 0`)
|
|
||||||
.run(id, ctx.sender, ctx.roomId);
|
|
||||||
|
|
||||||
if (result.changes > 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Reminder ${id} cancelled.`);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No pending reminder found with ID ${id}.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { XpPlugin } from "./xp";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const THANKS_REGEX = /\b(?:thanks?|thank\s*you|thx|ty|tysm|tyvm|cheers|kudos|props)\b.*?(@\S+:\S+)/i;
|
|
||||||
const THANKS_SIMPLE_REGEX = /\b(?:thanks?|thank\s*you|thx|ty|tysm|tyvm|cheers|kudos|props)\b/i;
|
|
||||||
const REP_XP_BONUS = 5;
|
|
||||||
const COOLDOWN_HOURS = 24;
|
|
||||||
const LLM_ENABLED = (process.env.OLLAMA_HOST ?? "") !== "" && (process.env.OLLAMA_MODEL ?? "") !== "";
|
|
||||||
|
|
||||||
export class ReputationPlugin extends Plugin {
|
|
||||||
private xpPlugin: XpPlugin;
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
|
||||||
super(client);
|
|
||||||
this.xpPlugin = xpPlugin;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "reputation";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "rep", description: "Show reputation score", usage: "!rep [@user]" },
|
|
||||||
{ name: "repboard", description: "Reputation leaderboard" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
// Passive thanks detection (disabled when LLM handles it with sarcasm awareness)
|
|
||||||
if (!LLM_ENABLED) this.detectThanks(ctx);
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "repboard")) {
|
|
||||||
await this.handleRepboard(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "rep")) {
|
|
||||||
await this.handleRep(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private detectThanks(ctx: MessageContext): void {
|
|
||||||
// Try to find a mentioned user in the thanks message
|
|
||||||
const mentionMatch = ctx.body.match(THANKS_REGEX);
|
|
||||||
if (!mentionMatch) return;
|
|
||||||
|
|
||||||
const receiverId = mentionMatch[1];
|
|
||||||
if (receiverId === ctx.sender) return; // Can't thank yourself
|
|
||||||
|
|
||||||
this.grantRep(ctx.sender, receiverId, ctx.roomId, ctx.eventId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private grantRep(giverId: string, receiverId: string, roomId: string, eventId: string): void {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Check 24h cooldown
|
|
||||||
const cooldown = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT granted_at FROM rep_cooldowns
|
|
||||||
WHERE giver_id = ? AND receiver_id = ? AND room_id = ?`
|
|
||||||
)
|
|
||||||
.get(giverId, receiverId, roomId) as { granted_at: string } | undefined;
|
|
||||||
|
|
||||||
if (cooldown) {
|
|
||||||
const grantedAt = new Date(cooldown.granted_at + "Z").getTime();
|
|
||||||
if (Date.now() - grantedAt < COOLDOWN_HOURS * 60 * 60 * 1000) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update or insert cooldown
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO rep_cooldowns (giver_id, receiver_id, room_id, granted_at)
|
|
||||||
VALUES (?, ?, ?, datetime('now'))
|
|
||||||
ON CONFLICT(giver_id, receiver_id, room_id) DO UPDATE SET granted_at = datetime('now')`
|
|
||||||
).run(giverId, receiverId, roomId);
|
|
||||||
|
|
||||||
// Grant rep
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO users (user_id, room_id, rep)
|
|
||||||
VALUES (?, ?, 1)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET rep = rep + 1`
|
|
||||||
).run(receiverId, roomId);
|
|
||||||
|
|
||||||
// Bonus XP
|
|
||||||
this.xpPlugin.grantXp(receiverId, roomId, REP_XP_BONUS, "reputation");
|
|
||||||
|
|
||||||
// React with checkmark to acknowledge
|
|
||||||
this.sendReact(roomId, eventId, "\u2705");
|
|
||||||
|
|
||||||
logger.debug(`${giverId} gave rep to ${receiverId} in ${roomId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRep(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "rep");
|
|
||||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT rep FROM users WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { rep: number } | undefined;
|
|
||||||
|
|
||||||
const rep = row?.rep ?? 0;
|
|
||||||
await this.sendMessage(ctx.roomId, `${targetUser} has ${rep} reputation point${rep !== 1 ? "s" : ""}.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRepboard(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT user_id, rep FROM users WHERE room_id = ? AND rep > 0 ORDER BY rep DESC LIMIT 10`)
|
|
||||||
.all(ctx.roomId) as { user_id: string; rep: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No reputation data yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — ${r.rep} rep`);
|
|
||||||
await this.sendMessage(ctx.roomId, `Reputation Leaderboard:\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,163 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const RAWG_API_KEY = process.env.RAWG_API_KEY ?? "";
|
|
||||||
const ENABLED = RAWG_API_KEY !== "";
|
|
||||||
const CACHE_TTL = 7 * 24 * 60 * 60; // 7 days
|
|
||||||
|
|
||||||
export class RetroPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "retro";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "game", description: "Game lookup via RAWG", usage: "!game <game>" },
|
|
||||||
{ name: "retro", description: "Alias for !game", usage: "!retro <game>" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "game")) {
|
|
||||||
await this.handleGameLookup(ctx, "game");
|
|
||||||
} else if (this.isCommand(ctx.body, "retro")) {
|
|
||||||
await this.handleGameLookup(ctx, "retro");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleGameLookup(ctx: MessageContext, command: string): Promise<void> {
|
|
||||||
if (!ENABLED) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API is not configured. Set RAWG_API_KEY.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const query = this.getArgs(ctx.body, command);
|
|
||||||
if (!query) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Usage: !${command} <game>`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const searchKey = query.toLowerCase();
|
|
||||||
|
|
||||||
// Check cache
|
|
||||||
const cached = db.prepare(
|
|
||||||
`SELECT data FROM retro_cache WHERE search_term = ? AND cached_at > unixepoch() - ?`
|
|
||||||
).get(searchKey, CACHE_TTL) as { data: string } | undefined;
|
|
||||||
|
|
||||||
let games: any[];
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
games = JSON.parse(cached.data);
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
// Search RAWG, sort by relevance
|
|
||||||
const url = `https://api.rawg.io/api/games?key=${RAWG_API_KEY}&search=${encodeURIComponent(query)}&page_size=3&search_precise=true`;
|
|
||||||
const res = await fetch(url, { signal: AbortSignal.timeout(10_000) });
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "RAWG API request failed.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { results: any[] };
|
|
||||||
games = data.results ?? [];
|
|
||||||
|
|
||||||
// Fetch details for the first result to get description and developers
|
|
||||||
if (games.length > 0) {
|
|
||||||
try {
|
|
||||||
const detailRes = await fetch(
|
|
||||||
`https://api.rawg.io/api/games/${games[0].id}?key=${RAWG_API_KEY}`,
|
|
||||||
{ signal: AbortSignal.timeout(10_000) }
|
|
||||||
);
|
|
||||||
if (detailRes.ok) {
|
|
||||||
const detail = (await detailRes.json()) as any;
|
|
||||||
games[0]._detail = {
|
|
||||||
description_raw: detail.description_raw,
|
|
||||||
developers: detail.developers,
|
|
||||||
publishers: detail.publishers,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
} catch { /* detail fetch is best-effort */ }
|
|
||||||
}
|
|
||||||
|
|
||||||
if (games.length > 0) {
|
|
||||||
db.prepare(
|
|
||||||
`INSERT INTO retro_cache (search_term, data) VALUES (?, ?)
|
|
||||||
ON CONFLICT(search_term) DO UPDATE SET data = excluded.data, cached_at = unixepoch()`
|
|
||||||
).run(searchKey, JSON.stringify(games));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Retro game lookup failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to look up that game.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!games || games.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No results found.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const reply = games.map((g, i) => this.formatGame(g, i === 0)).join("\n\n---\n\n");
|
|
||||||
await this.sendMessage(ctx.roomId, reply);
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatGame(game: any, detailed: boolean): string {
|
|
||||||
const name = game.name ?? "Unknown";
|
|
||||||
|
|
||||||
// Release year
|
|
||||||
let year = "Unknown";
|
|
||||||
if (game.released) {
|
|
||||||
year = game.released.slice(0, 4);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Platforms
|
|
||||||
const platforms = (game.platforms ?? [])
|
|
||||||
.map((p: any) => p.platform?.name)
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(", ") || "Unknown";
|
|
||||||
|
|
||||||
// Genres
|
|
||||||
const genres = (game.genres ?? [])
|
|
||||||
.map((g: any) => g.name)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
// Rating
|
|
||||||
let rating = "";
|
|
||||||
if (game.metacritic) {
|
|
||||||
rating = `Metacritic: ${game.metacritic}`;
|
|
||||||
} else if (game.rating) {
|
|
||||||
rating = `Rating: ${game.rating}/5`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Developers & publishers (from detail fetch)
|
|
||||||
const developers = (game._detail?.developers ?? [])
|
|
||||||
.map((d: any) => d.name)
|
|
||||||
.join(", ");
|
|
||||||
const publishers = (game._detail?.publishers ?? [])
|
|
||||||
.map((p: any) => p.name)
|
|
||||||
.join(", ");
|
|
||||||
|
|
||||||
let lines = [`${name} (${year})`];
|
|
||||||
lines.push(`Platforms: ${platforms}`);
|
|
||||||
if (developers) lines.push(`Developer: ${developers}`);
|
|
||||||
if (publishers) lines.push(`Publisher: ${publishers}`);
|
|
||||||
if (genres) lines.push(`Genre: ${genres}`);
|
|
||||||
if (rating) lines.push(rating);
|
|
||||||
|
|
||||||
if (detailed && game._detail?.description_raw) {
|
|
||||||
let desc = game._detail.description_raw;
|
|
||||||
if (desc.length > 300) desc = desc.slice(0, 300) + "...";
|
|
||||||
lines.push("");
|
|
||||||
lines.push(desc);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.join("\n");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
|
|
||||||
const COMING_SOON = "This feature is coming soon.";
|
|
||||||
|
|
||||||
export class ShadePlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "shade";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "shadecheck", description: "(stub) Coming soon", usage: "!shadecheck [@user]" },
|
|
||||||
{ name: "shadeboard", description: "(stub) Coming soon" },
|
|
||||||
{ name: "perpetrators", description: "(stub) Coming soon" },
|
|
||||||
{ name: "receipts", description: "(stub) Coming soon" },
|
|
||||||
{ name: "shadewar", description: "(stub) Coming soon" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
const stubCommands = ["shadecheck", "shadeboard", "perpetrators", "receipts", "shadewar"];
|
|
||||||
for (const cmd of stubCommands) {
|
|
||||||
if (this.isCommand(ctx.body, cmd)) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, COMING_SOON);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,228 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import { parseMessage, deriveArchetype } from "../utils/parser";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const MILESTONES = [1000, 5000, 10000, 25000, 50000, 100000, 250000, 500000, 1000000];
|
|
||||||
|
|
||||||
export class StatsPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "stats";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "stats", description: "Full message metrics", usage: "!stats [@user]" },
|
|
||||||
{ name: "rankings", description: "Category leaderboard", usage: "!rankings [category] [month]" },
|
|
||||||
{ name: "personality", description: "Derived community archetype", usage: "!personality [@user]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
// Passive: track stats for every message
|
|
||||||
this.trackMessage(ctx);
|
|
||||||
this.checkMilestone(ctx);
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "personality")) {
|
|
||||||
await this.handlePersonality(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "stats")) {
|
|
||||||
await this.handleStats(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "rankings")) {
|
|
||||||
await this.handleRankings(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private trackMessage(ctx: MessageContext): void {
|
|
||||||
const parsed = parseMessage(ctx.body);
|
|
||||||
const db = getDb();
|
|
||||||
const now = new Date();
|
|
||||||
const hour = now.getUTCHours();
|
|
||||||
const day = now.getUTCDay();
|
|
||||||
|
|
||||||
// Get existing stats to update distributions
|
|
||||||
const existing = db
|
|
||||||
.prepare(`SELECT hourly_distribution, daily_distribution, total_messages, total_words FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(ctx.sender, ctx.roomId) as
|
|
||||||
| { hourly_distribution: string; daily_distribution: string; total_messages: number; total_words: number }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
const hourly: Record<string, number> = JSON.parse(existing?.hourly_distribution ?? "{}");
|
|
||||||
const daily: Record<string, number> = JSON.parse(existing?.daily_distribution ?? "{}");
|
|
||||||
hourly[hour] = (hourly[hour] ?? 0) + 1;
|
|
||||||
daily[day] = (daily[day] ?? 0) + 1;
|
|
||||||
|
|
||||||
const newTotalMessages = (existing?.total_messages ?? 0) + 1;
|
|
||||||
const newTotalWords = (existing?.total_words ?? 0) + parsed.wordCount;
|
|
||||||
const newAvgWords = newTotalWords / newTotalMessages;
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO user_stats (
|
|
||||||
user_id, room_id, total_messages, total_words, total_characters,
|
|
||||||
total_links, total_images, total_questions, total_exclamations,
|
|
||||||
total_emojis, longest_message, shortest_message, avg_words_per_message,
|
|
||||||
hourly_distribution, daily_distribution, last_active_date
|
|
||||||
) VALUES (?, ?, 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, date('now'))
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
|
||||||
total_messages = total_messages + 1,
|
|
||||||
total_words = total_words + ?,
|
|
||||||
total_characters = total_characters + ?,
|
|
||||||
total_links = total_links + ?,
|
|
||||||
total_images = total_images + ?,
|
|
||||||
total_questions = total_questions + ?,
|
|
||||||
total_exclamations = total_exclamations + ?,
|
|
||||||
total_emojis = total_emojis + ?,
|
|
||||||
longest_message = MAX(longest_message, ?),
|
|
||||||
shortest_message = MIN(COALESCE(shortest_message, ?), ?),
|
|
||||||
avg_words_per_message = ?,
|
|
||||||
hourly_distribution = ?,
|
|
||||||
daily_distribution = ?,
|
|
||||||
last_active_date = date('now')
|
|
||||||
`).run(
|
|
||||||
ctx.sender,
|
|
||||||
ctx.roomId,
|
|
||||||
parsed.wordCount,
|
|
||||||
parsed.charCount,
|
|
||||||
parsed.linkCount,
|
|
||||||
parsed.imageCount,
|
|
||||||
parsed.questionCount,
|
|
||||||
parsed.exclamationCount,
|
|
||||||
parsed.emojiCount,
|
|
||||||
parsed.charCount,
|
|
||||||
parsed.charCount,
|
|
||||||
parsed.wordCount,
|
|
||||||
JSON.stringify(hourly),
|
|
||||||
JSON.stringify(daily),
|
|
||||||
// ON CONFLICT params:
|
|
||||||
parsed.wordCount,
|
|
||||||
parsed.charCount,
|
|
||||||
parsed.linkCount,
|
|
||||||
parsed.imageCount,
|
|
||||||
parsed.questionCount,
|
|
||||||
parsed.exclamationCount,
|
|
||||||
parsed.emojiCount,
|
|
||||||
parsed.charCount,
|
|
||||||
parsed.charCount,
|
|
||||||
parsed.charCount,
|
|
||||||
newAvgWords,
|
|
||||||
JSON.stringify(hourly),
|
|
||||||
JSON.stringify(daily)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkMilestone(ctx: MessageContext): void {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(
|
|
||||||
`INSERT INTO room_milestones (room_id, total_messages, last_milestone)
|
|
||||||
VALUES (?, 1, 0)
|
|
||||||
ON CONFLICT(room_id) DO UPDATE SET total_messages = total_messages + 1
|
|
||||||
RETURNING total_messages, last_milestone`
|
|
||||||
)
|
|
||||||
.get(ctx.roomId) as { total_messages: number; last_milestone: number };
|
|
||||||
|
|
||||||
const nextMilestone = MILESTONES.find((m) => m > row.last_milestone && m <= row.total_messages);
|
|
||||||
if (nextMilestone) {
|
|
||||||
db.prepare(`UPDATE room_milestones SET last_milestone = ? WHERE room_id = ?`)
|
|
||||||
.run(nextMilestone, ctx.roomId);
|
|
||||||
|
|
||||||
const formatted = nextMilestone >= 1000000
|
|
||||||
? `${(nextMilestone / 1000000).toFixed(nextMilestone % 1000000 === 0 ? 0 : 1)}M`
|
|
||||||
: nextMilestone >= 1000
|
|
||||||
? `${(nextMilestone / 1000).toFixed(nextMilestone % 1000 === 0 ? 0 : 1)}k`
|
|
||||||
: String(nextMilestone);
|
|
||||||
|
|
||||||
this.sendMessage(ctx.roomId, `This room just hit ${formatted} messages! Congrats to everyone who contributed to this milestone.`)
|
|
||||||
.catch((err) => logger.error(`Failed to send milestone: ${err}`));
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Milestone check failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStats(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "stats");
|
|
||||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT * FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as any;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No stats found for ${targetUser}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = [
|
|
||||||
`Stats for ${targetUser}:`,
|
|
||||||
`Messages: ${row.total_messages} | Words: ${row.total_words}`,
|
|
||||||
`Avg words/msg: ${row.avg_words_per_message.toFixed(1)}`,
|
|
||||||
`Links: ${row.total_links} | Images: ${row.total_images}`,
|
|
||||||
`Questions: ${row.total_questions} | Exclamations: ${row.total_exclamations}`,
|
|
||||||
`Emojis: ${row.total_emojis}`,
|
|
||||||
`Longest msg: ${row.longest_message} chars`,
|
|
||||||
`Streak: ${row.current_streak}d (record: ${row.longest_streak}d)`,
|
|
||||||
];
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRankings(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "rankings");
|
|
||||||
const parts = args.split(/\s+/);
|
|
||||||
const category = parts[0] || "messages";
|
|
||||||
|
|
||||||
const columnMap: Record<string, string> = {
|
|
||||||
messages: "total_messages",
|
|
||||||
words: "total_words",
|
|
||||||
links: "total_links",
|
|
||||||
images: "total_images",
|
|
||||||
questions: "total_questions",
|
|
||||||
emojis: "total_emojis",
|
|
||||||
streak: "longest_streak",
|
|
||||||
};
|
|
||||||
|
|
||||||
const column = columnMap[category];
|
|
||||||
if (!column) {
|
|
||||||
const valid = Object.keys(columnMap).join(", ");
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Unknown category. Valid: ${valid}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT user_id, ${column} as value FROM user_stats WHERE room_id = ? ORDER BY ${column} DESC LIMIT 10`)
|
|
||||||
.all(ctx.roomId) as { user_id: string; value: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No data yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — ${r.value}`);
|
|
||||||
await this.sendMessage(ctx.roomId, `Rankings (${category}):\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handlePersonality(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "personality");
|
|
||||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT total_messages, avg_words_per_message, total_questions, total_links, total_images, total_exclamations, hourly_distribution FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as any;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No data found for ${targetUser}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const archetype = deriveArchetype(row);
|
|
||||||
await this.sendMessage(ctx.roomId, `${targetUser}'s community archetype: ${archetype}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const CACHE_TTL_QUOTE = 15 * 60; // 15 minutes
|
|
||||||
const CACHE_TTL_PROFILE = 24 * 60 * 60; // 24 hours
|
|
||||||
const CACHE_TTL_METRICS = 60 * 60; // 1 hour
|
|
||||||
const COOLDOWN_MS = 60_000; // 60 seconds per user
|
|
||||||
|
|
||||||
export class StocksPlugin extends Plugin {
|
|
||||||
private cooldowns: Map<string, number> = new Map();
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
this.initDb();
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "stocks";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "stock", description: "Get stock price info", usage: "!stock <TICKER> [TICKER2] [TICKER3]" },
|
|
||||||
{ name: "stockwatch", description: "Manage your stock watchlist", usage: "!stockwatch <TICKER|list|remove TICKER>" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private initDb(): void {
|
|
||||||
const db = getDb();
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS stocks_cache (
|
|
||||||
ticker TEXT PRIMARY KEY,
|
|
||||||
data TEXT NOT NULL,
|
|
||||||
cached_at INTEGER DEFAULT (unixepoch())
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS stock_watchlist (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
user_id TEXT,
|
|
||||||
room_id TEXT,
|
|
||||||
ticker TEXT,
|
|
||||||
created_at INTEGER,
|
|
||||||
UNIQUE(user_id, room_id, ticker)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private get apiKey(): string | undefined {
|
|
||||||
return process.env.FINNHUB_API_KEY;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "stockwatch")) {
|
|
||||||
await this.handleStockwatch(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "stock")) {
|
|
||||||
await this.handleStock(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkCooldown(userId: string): number | null {
|
|
||||||
const last = this.cooldowns.get(userId);
|
|
||||||
if (last) {
|
|
||||||
const elapsed = Date.now() - last;
|
|
||||||
if (elapsed < COOLDOWN_MS) {
|
|
||||||
return Math.ceil((COOLDOWN_MS - elapsed) / 1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private setCooldown(userId: string): void {
|
|
||||||
this.cooldowns.set(userId, Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStock(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Finnhub API key not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "stock");
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !stock <TICKER> [TICKER2] [TICKER3]");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const remaining = this.checkCooldown(ctx.sender);
|
|
||||||
if (remaining !== null) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Please wait ${remaining}s before requesting stock data again.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tickers = args.split(/\s+/).map((t) => t.toUpperCase());
|
|
||||||
const blocks: string[] = [];
|
|
||||||
|
|
||||||
for (const ticker of tickers) {
|
|
||||||
try {
|
|
||||||
const block = await this.getStockBlock(ticker);
|
|
||||||
blocks.push(block);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Stock lookup failed for ${ticker}: ${err}`);
|
|
||||||
blocks.push(`${ticker} — Failed to retrieve data.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.setCooldown(ctx.sender);
|
|
||||||
await this.sendMessage(ctx.roomId, blocks.join("\n\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getStockBlock(ticker: string): Promise<string> {
|
|
||||||
const quote = await this.fetchQuote(ticker);
|
|
||||||
if (!quote || quote.c === 0) {
|
|
||||||
return `${ticker} — No data found. Verify the ticker symbol.`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const profile = await this.fetchProfile(ticker);
|
|
||||||
const metrics = await this.fetchMetrics(ticker);
|
|
||||||
|
|
||||||
const change = quote.d ?? 0;
|
|
||||||
const pctChange = quote.dp ?? 0;
|
|
||||||
const arrow = change >= 0 ? "\u25B2" : "\u25BC";
|
|
||||||
const sign = change >= 0 ? "+" : "";
|
|
||||||
|
|
||||||
const header = profile && profile.name
|
|
||||||
? `${ticker} \u2014 ${profile.name}${profile.exchange ? ` (${profile.exchange})` : ""}`
|
|
||||||
: ticker;
|
|
||||||
|
|
||||||
const price = `$${quote.c.toFixed(2)} ${arrow} ${sign}${change.toFixed(2)} (${sign}${pctChange.toFixed(2)}%)`;
|
|
||||||
|
|
||||||
const high52 = metrics?.metric?.["52WeekHigh"];
|
|
||||||
const low52 = metrics?.metric?.["52WeekLow"];
|
|
||||||
const weekRange = high52 != null && low52 != null
|
|
||||||
? `$${low52.toFixed(2)} \u2013 $${high52.toFixed(2)}`
|
|
||||||
: "N/A";
|
|
||||||
|
|
||||||
const ts = quote.t ? new Date(quote.t * 1000) : null;
|
|
||||||
const timeStr = ts
|
|
||||||
? `${ts.getUTCHours().toString().padStart(2, "0")}:${ts.getUTCMinutes().toString().padStart(2, "0")} UTC`
|
|
||||||
: "N/A";
|
|
||||||
|
|
||||||
return [
|
|
||||||
header,
|
|
||||||
`$${quote.c.toFixed(2)} ${arrow} ${sign}${change.toFixed(2)} (${sign}${pctChange.toFixed(2)}%)`,
|
|
||||||
`Volume: N/A | 52W: ${weekRange}`,
|
|
||||||
`Last updated: ${timeStr}`,
|
|
||||||
].join("\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
private getCached(key: string, maxAge: number): any | null {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT data, cached_at FROM stocks_cache WHERE ticker = ?`)
|
|
||||||
.get(key) as { data: string; cached_at: number } | undefined;
|
|
||||||
|
|
||||||
if (!row) return null;
|
|
||||||
|
|
||||||
const age = Math.floor(Date.now() / 1000) - row.cached_at;
|
|
||||||
if (age > maxAge) return null;
|
|
||||||
|
|
||||||
try {
|
|
||||||
return JSON.parse(row.data);
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setCache(key: string, data: any): void {
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`INSERT OR REPLACE INTO stocks_cache (ticker, data, cached_at) VALUES (?, ?, unixepoch())`)
|
|
||||||
.run(key, JSON.stringify(data));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchQuote(ticker: string): Promise<any> {
|
|
||||||
const cacheKey = `quote:${ticker}`;
|
|
||||||
const cached = this.getCached(cacheKey, CACHE_TTL_QUOTE);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const url = `https://finnhub.io/api/v1/quote?symbol=${encodeURIComponent(ticker)}&token=${encodeURIComponent(this.apiKey!)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Finnhub quote API returned ${res.status} for ${ticker}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
this.setCache(cacheKey, data);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchProfile(ticker: string): Promise<any> {
|
|
||||||
const cacheKey = `profile:${ticker}`;
|
|
||||||
const cached = this.getCached(cacheKey, CACHE_TTL_PROFILE);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const url = `https://finnhub.io/api/v1/stock/profile2?symbol=${encodeURIComponent(ticker)}&token=${encodeURIComponent(this.apiKey!)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Finnhub profile API returned ${res.status} for ${ticker}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
this.setCache(cacheKey, data);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchMetrics(ticker: string): Promise<any> {
|
|
||||||
const cacheKey = `metrics:${ticker}`;
|
|
||||||
const cached = this.getCached(cacheKey, CACHE_TTL_METRICS);
|
|
||||||
if (cached) return cached;
|
|
||||||
|
|
||||||
const url = `https://finnhub.io/api/v1/stock/metric?symbol=${encodeURIComponent(ticker)}&metric=all&token=${encodeURIComponent(this.apiKey!)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Finnhub metrics API returned ${res.status} for ${ticker}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json();
|
|
||||||
this.setCache(cacheKey, data);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStockwatch(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!this.apiKey) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Finnhub API key not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "stockwatch").trim();
|
|
||||||
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !stockwatch <TICKER> | !stockwatch list | !stockwatch remove <TICKER>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.toLowerCase() === "list") {
|
|
||||||
await this.handleWatchlistList(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.toLowerCase().startsWith("remove")) {
|
|
||||||
const ticker = args.slice(6).trim().toUpperCase();
|
|
||||||
if (!ticker) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !stockwatch remove <TICKER>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.handleWatchlistRemove(ctx, ticker);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticker = args.toUpperCase();
|
|
||||||
await this.handleWatchlistAdd(ctx, ticker);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWatchlistAdd(ctx: MessageContext, ticker: string): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`INSERT OR IGNORE INTO stock_watchlist (user_id, room_id, ticker, created_at) VALUES (?, ?, ?, unixepoch())`)
|
|
||||||
.run(ctx.sender, ctx.roomId, ticker);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Added ${ticker} to your watchlist.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWatchlistRemove(ctx: MessageContext, ticker: string): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const result = db
|
|
||||||
.prepare(`DELETE FROM stock_watchlist WHERE user_id = ? AND room_id = ? AND ticker = ?`)
|
|
||||||
.run(ctx.sender, ctx.roomId, ticker);
|
|
||||||
|
|
||||||
if (result.changes > 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Removed ${ticker} from your watchlist.`);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `${ticker} is not on your watchlist.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWatchlistList(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT ticker FROM stock_watchlist WHERE user_id = ? AND room_id = ? ORDER BY ticker ASC`)
|
|
||||||
.all(ctx.sender, ctx.roomId) as { ticker: string }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your watchlist is empty. Use !stockwatch <TICKER> to add one.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((row) => {
|
|
||||||
const cached = this.getCached(`quote:${row.ticker}`, CACHE_TTL_QUOTE);
|
|
||||||
if (cached && cached.c) {
|
|
||||||
const change = cached.d ?? 0;
|
|
||||||
const sign = change >= 0 ? "+" : "";
|
|
||||||
const arrow = change >= 0 ? "\u25B2" : "\u25BC";
|
|
||||||
return `${row.ticker} $${cached.c.toFixed(2)} ${arrow} ${sign}${change.toFixed(2)}`;
|
|
||||||
}
|
|
||||||
return `${row.ticker} (no data)`;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, `Your watchlist:\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
|
|
||||||
export class StreaksPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "streaks";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "streak", description: "Current and record streak", usage: "!streak [@user]" },
|
|
||||||
{ name: "firstboard", description: "Early bird leaderboard", usage: "!firstboard [@user|month]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
// Passive: track daily activity and first poster
|
|
||||||
this.trackActivity(ctx);
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "firstboard")) {
|
|
||||||
await this.handleFirstboard(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "streak")) {
|
|
||||||
await this.handleStreak(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private trackActivity(ctx: MessageContext): void {
|
|
||||||
const db = getDb();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// Track daily activity
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO daily_activity (user_id, room_id, date, message_count)
|
|
||||||
VALUES (?, ?, ?, 1)
|
|
||||||
ON CONFLICT(user_id, room_id, date) DO UPDATE SET message_count = message_count + 1
|
|
||||||
`).run(ctx.sender, ctx.roomId, today);
|
|
||||||
|
|
||||||
// Track first poster (INSERT OR IGNORE — first one wins)
|
|
||||||
db.prepare(`
|
|
||||||
INSERT OR IGNORE INTO daily_first (room_id, date, user_id, timestamp)
|
|
||||||
VALUES (?, ?, ?, datetime('now'))
|
|
||||||
`).run(ctx.roomId, today, ctx.sender);
|
|
||||||
|
|
||||||
// Update streak in user_stats
|
|
||||||
this.updateStreak(ctx.sender, ctx.roomId, today);
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateStreak(userId: string, roomId: string, today: string): void {
|
|
||||||
const db = getDb();
|
|
||||||
const stats = db
|
|
||||||
.prepare(`SELECT last_active_date, current_streak, longest_streak FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(userId, roomId) as
|
|
||||||
| { last_active_date: string | null; current_streak: number; longest_streak: number }
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
if (!stats) return;
|
|
||||||
|
|
||||||
const lastDate = stats.last_active_date;
|
|
||||||
if (lastDate === today) return; // Already counted today
|
|
||||||
|
|
||||||
// Check if yesterday was active
|
|
||||||
const yesterday = new Date();
|
|
||||||
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
|
|
||||||
const yesterdayStr = yesterday.toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
let newStreak: number;
|
|
||||||
if (lastDate === yesterdayStr) {
|
|
||||||
newStreak = stats.current_streak + 1;
|
|
||||||
} else {
|
|
||||||
newStreak = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const newLongest = Math.max(stats.longest_streak, newStreak);
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE user_stats SET current_streak = ?, longest_streak = ?
|
|
||||||
WHERE user_id = ? AND room_id = ?
|
|
||||||
`).run(newStreak, newLongest, userId, roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStreak(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "streak");
|
|
||||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const stats = db
|
|
||||||
.prepare(`SELECT current_streak, longest_streak FROM user_stats WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { current_streak: number; longest_streak: number } | undefined;
|
|
||||||
|
|
||||||
if (!stats) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No streak data for ${targetUser}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(
|
|
||||||
ctx.roomId,
|
|
||||||
`${targetUser}'s streak: ${stats.current_streak} day${stats.current_streak !== 1 ? "s" : ""} (record: ${stats.longest_streak})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFirstboard(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const rows = db
|
|
||||||
.prepare(`
|
|
||||||
SELECT user_id, COUNT(*) as firsts
|
|
||||||
FROM daily_first
|
|
||||||
WHERE room_id = ?
|
|
||||||
GROUP BY user_id
|
|
||||||
ORDER BY firsts DESC
|
|
||||||
LIMIT 10
|
|
||||||
`)
|
|
||||||
.all(ctx.roomId) as { user_id: string; firsts: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No first-poster data yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — ${r.firsts} first${r.firsts !== 1 ? "s" : ""}`);
|
|
||||||
await this.sendMessage(ctx.roomId, `Early Bird Leaderboard:\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { evaluate } from "mathjs";
|
|
||||||
import QRCode from "qrcode";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
export class ToolsPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "tools";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "calc", description: "Inline calculator", usage: "!calc <expression>" },
|
|
||||||
{ name: "qr", description: "Generate a QR code image", usage: "!qr <text or URL>" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "calc")) {
|
|
||||||
await this.handleCalc(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "qr")) {
|
|
||||||
await this.handleQr(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCalc(ctx: MessageContext): Promise<void> {
|
|
||||||
const expr = this.getArgs(ctx.body, "calc");
|
|
||||||
if (!expr) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !calc <expression>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Normalize natural language patterns
|
|
||||||
let normalized = expr
|
|
||||||
.replace(/(\d),(\d)/g, "$1$2") // strip thousands commas: 450,000 -> 450000
|
|
||||||
.replace(/(\d+(?:\.\d+)?)\s*%\s*of\s*(\d+(?:\.\d+)?)/gi, "($1 / 100) * $2")
|
|
||||||
.replace(/(\d+(?:\.\d+)?)\s*%/g, "($1 / 100)");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = evaluate(normalized);
|
|
||||||
const display = typeof result === "number"
|
|
||||||
? Number.isInteger(result) ? result.toLocaleString("en-US") : parseFloat(result.toFixed(10)).toLocaleString("en-US")
|
|
||||||
: String(result);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `${expr} = ${display}`);
|
|
||||||
} catch (err) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Could not evaluate: ${expr}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleQr(ctx: MessageContext): Promise<void> {
|
|
||||||
const text = this.getArgs(ctx.body, "qr");
|
|
||||||
if (!text) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !qr <text or URL>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pngBuffer = await QRCode.toBuffer(text, { type: "png", width: 300, margin: 2 });
|
|
||||||
const mxcUrl = await this.client.uploadContent(pngBuffer, "image/png", "qrcode.png");
|
|
||||||
|
|
||||||
await this.client.sendMessage(ctx.roomId, {
|
|
||||||
msgtype: "m.image",
|
|
||||||
body: "qrcode.png",
|
|
||||||
url: mxcUrl,
|
|
||||||
info: {
|
|
||||||
mimetype: "image/png",
|
|
||||||
w: 300,
|
|
||||||
h: 300,
|
|
||||||
size: pngBuffer.length,
|
|
||||||
},
|
|
||||||
"m.relates_to": {
|
|
||||||
"m.in_reply_to": { event_id: ctx.eventId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`QR generation failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to generate QR code.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,667 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { XpPlugin } from "./xp";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const TRIVIA_TIMEOUT_SECONDS = parseInt(process.env.TRIVIA_TIMEOUT_SECONDS ?? "20", 10);
|
|
||||||
const FULL_POINTS = 100;
|
|
||||||
const FULL_POINTS_WINDOW_MS = 3000;
|
|
||||||
|
|
||||||
interface OpenTDBQuestion {
|
|
||||||
category: string;
|
|
||||||
type: string;
|
|
||||||
difficulty: string;
|
|
||||||
question: string;
|
|
||||||
correct_answer: string;
|
|
||||||
incorrect_answers: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ActiveQuestion {
|
|
||||||
sessionId: number;
|
|
||||||
answer: string;
|
|
||||||
answerIndex: number;
|
|
||||||
options: string[];
|
|
||||||
timeout: ReturnType<typeof setTimeout>;
|
|
||||||
askedAt: number;
|
|
||||||
startedBy: string;
|
|
||||||
threadEventId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CATEGORY_MAP: Record<number, string> = {
|
|
||||||
9: "General Knowledge",
|
|
||||||
10: "Books",
|
|
||||||
11: "Film",
|
|
||||||
12: "Music",
|
|
||||||
13: "Musicals & Theatre",
|
|
||||||
14: "Television",
|
|
||||||
15: "Video Games",
|
|
||||||
16: "Board Games",
|
|
||||||
17: "Science & Nature",
|
|
||||||
18: "Computers",
|
|
||||||
19: "Mathematics",
|
|
||||||
20: "Mythology",
|
|
||||||
21: "Sports",
|
|
||||||
22: "Geography",
|
|
||||||
23: "History",
|
|
||||||
24: "Politics",
|
|
||||||
25: "Art",
|
|
||||||
26: "Celebrities",
|
|
||||||
27: "Animals",
|
|
||||||
28: "Vehicles",
|
|
||||||
29: "Comics",
|
|
||||||
30: "Gadgets",
|
|
||||||
31: "Anime & Manga",
|
|
||||||
32: "Cartoons",
|
|
||||||
};
|
|
||||||
|
|
||||||
function decodeHtml(html: string): string {
|
|
||||||
return html
|
|
||||||
.replace(/&/g, "&")
|
|
||||||
.replace(/</g, "<")
|
|
||||||
.replace(/>/g, ">")
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, "'");
|
|
||||||
}
|
|
||||||
|
|
||||||
function shuffleArray<T>(arr: T[]): T[] {
|
|
||||||
const shuffled = [...arr];
|
|
||||||
for (let i = shuffled.length - 1; i > 0; i--) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]];
|
|
||||||
}
|
|
||||||
return shuffled;
|
|
||||||
}
|
|
||||||
|
|
||||||
function calculatePoints(elapsedMs: number): number {
|
|
||||||
if (elapsedMs <= FULL_POINTS_WINDOW_MS) return FULL_POINTS;
|
|
||||||
const timeoutMs = TRIVIA_TIMEOUT_SECONDS * 1000;
|
|
||||||
if (elapsedMs >= timeoutMs) return 0;
|
|
||||||
const decayRange = timeoutMs - FULL_POINTS_WINDOW_MS;
|
|
||||||
const elapsed = elapsedMs - FULL_POINTS_WINDOW_MS;
|
|
||||||
return Math.round(FULL_POINTS * (1 - elapsed / decayRange));
|
|
||||||
}
|
|
||||||
|
|
||||||
function fuzzyMatchCategory(input: string): number | null {
|
|
||||||
const lower = input.toLowerCase();
|
|
||||||
for (const [id, name] of Object.entries(CATEGORY_MAP)) {
|
|
||||||
if (name.toLowerCase().includes(lower) || lower.includes(name.toLowerCase())) {
|
|
||||||
return parseInt(id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class TriviaPlugin extends Plugin {
|
|
||||||
private xpPlugin: XpPlugin;
|
|
||||||
private questionCache = new Map<string, OpenTDBQuestion[]>(); // cacheKey -> questions
|
|
||||||
private activeQuestions = new Map<string, ActiveQuestion>();
|
|
||||||
private triviaThreads = new Map<string, { eventId: string; categoryId?: number; difficulty?: string }>(); // roomId -> thread info
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
|
||||||
super(client);
|
|
||||||
this.xpPlugin = xpPlugin;
|
|
||||||
this.initDb();
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "trivia";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "trivia", description: "Start a trivia question", usage: "!trivia [category] [easy|medium|hard]" },
|
|
||||||
{ name: "trivia stop", description: "Cancel the active question" },
|
|
||||||
{ name: "trivia scores", description: "Trivia leaderboard", usage: "!trivia scores [@user|month]" },
|
|
||||||
{ name: "trivia categories", description: "List available trivia categories" },
|
|
||||||
{ name: "trivia fastest", description: "Top 10 fastest correct answers" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private initDb(): void {
|
|
||||||
const db = getDb();
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS trivia_sessions (
|
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
||||||
room_id TEXT,
|
|
||||||
question TEXT,
|
|
||||||
answer TEXT,
|
|
||||||
category TEXT,
|
|
||||||
difficulty TEXT,
|
|
||||||
asked_at INTEGER DEFAULT (unixepoch()),
|
|
||||||
answered_by TEXT,
|
|
||||||
answered_at INTEGER,
|
|
||||||
correct INTEGER DEFAULT 0
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS trivia_scores (
|
|
||||||
user_id TEXT,
|
|
||||||
room_id TEXT,
|
|
||||||
total_correct INTEGER DEFAULT 0,
|
|
||||||
total_points INTEGER DEFAULT 0,
|
|
||||||
total_answered INTEGER DEFAULT 0,
|
|
||||||
current_streak INTEGER DEFAULT 0,
|
|
||||||
best_streak INTEGER DEFAULT 0,
|
|
||||||
fastest_ms INTEGER,
|
|
||||||
PRIMARY KEY(user_id, room_id)
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private cacheKey(roomId: string, categoryId?: number, difficulty?: string): string {
|
|
||||||
return `${roomId}:${categoryId ?? "any"}:${difficulty ?? "any"}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchQuestions(roomId: string, categoryId?: number, difficulty?: string): Promise<void> {
|
|
||||||
const key = this.cacheKey(roomId, categoryId, difficulty);
|
|
||||||
try {
|
|
||||||
let url = "https://opentdb.com/api.php?amount=50&type=multiple";
|
|
||||||
if (categoryId) url += `&category=${categoryId}`;
|
|
||||||
if (difficulty) url += `&difficulty=${difficulty}`;
|
|
||||||
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.error(`OpenTDB API returned ${res.status}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { response_code: number; results: OpenTDBQuestion[] };
|
|
||||||
if (data.response_code !== 0 || !data.results.length) {
|
|
||||||
// Try boolean type as fallback
|
|
||||||
let boolUrl = "https://opentdb.com/api.php?amount=50&type=boolean";
|
|
||||||
if (categoryId) boolUrl += `&category=${categoryId}`;
|
|
||||||
if (difficulty) boolUrl += `&difficulty=${difficulty}`;
|
|
||||||
|
|
||||||
const boolRes = await fetch(boolUrl);
|
|
||||||
if (boolRes.ok) {
|
|
||||||
const boolData = (await boolRes.json()) as { response_code: number; results: OpenTDBQuestion[] };
|
|
||||||
if (boolData.response_code === 0) {
|
|
||||||
const cache = this.questionCache.get(key) ?? [];
|
|
||||||
cache.push(...boolData.results);
|
|
||||||
this.questionCache.set(key, cache);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cache = this.questionCache.get(key) ?? [];
|
|
||||||
cache.push(...data.results);
|
|
||||||
this.questionCache.set(key, cache);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to fetch trivia questions: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private getMessageThreadId(ctx: MessageContext): string | undefined {
|
|
||||||
const relatesTo = ctx.event.content?.["m.relates_to"];
|
|
||||||
if (relatesTo?.rel_type === "m.thread") {
|
|
||||||
return relatesTo.event_id;
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendThreadMessage(roomId: string, threadEventId: string, text: string): Promise<string> {
|
|
||||||
return await this.client.sendMessage(roomId, {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: text,
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.thread",
|
|
||||||
event_id: threadEventId,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async sendThreadReply(roomId: string, threadEventId: string, replyToEventId: string, text: string): Promise<string> {
|
|
||||||
return await this.client.sendMessage(roomId, {
|
|
||||||
msgtype: "m.text",
|
|
||||||
body: text,
|
|
||||||
"m.relates_to": {
|
|
||||||
rel_type: "m.thread",
|
|
||||||
event_id: threadEventId,
|
|
||||||
"is_falling_back": false,
|
|
||||||
"m.in_reply_to": { event_id: replyToEventId },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private async getOrCreateThread(roomId: string, categoryId?: number, difficulty?: string): Promise<string> {
|
|
||||||
const existing = this.triviaThreads.get(roomId);
|
|
||||||
// Reuse existing thread if category matches (or no category specified)
|
|
||||||
if (existing) {
|
|
||||||
const categoryChanged = categoryId !== undefined && categoryId !== existing.categoryId;
|
|
||||||
if (!categoryChanged) return existing.eventId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const catName = categoryId ? CATEGORY_MAP[categoryId] : null;
|
|
||||||
const label = catName ? `Trivia Time: ${catName}!` : "Trivia Time!";
|
|
||||||
const threadRootId = await this.sendMessage(roomId, `${label} Answer trivia questions in this thread.`);
|
|
||||||
this.triviaThreads.set(roomId, { eventId: threadRootId, categoryId, difficulty });
|
|
||||||
return threadRootId;
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
const inThreadId = this.getMessageThreadId(ctx);
|
|
||||||
const roomThread = this.triviaThreads.get(ctx.roomId);
|
|
||||||
const roomThreadId = roomThread?.eventId;
|
|
||||||
const isInTriviaThread = !!(inThreadId && inThreadId === roomThreadId);
|
|
||||||
const active = this.activeQuestions.get(ctx.roomId);
|
|
||||||
|
|
||||||
// Check for answer attempts — only accept from within the trivia thread
|
|
||||||
if (active && isInTriviaThread) {
|
|
||||||
const trimmed = ctx.body.trim().toLowerCase();
|
|
||||||
const isMultipleChoice = active.options.length === 4;
|
|
||||||
const validLetters = isMultipleChoice ? ["a", "b", "c", "d"] : ["true", "false"];
|
|
||||||
|
|
||||||
if (validLetters.includes(trimmed)) {
|
|
||||||
await this.handleAnswer(ctx, active, trimmed);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isCommand(ctx.body, "trivia")) return;
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "trivia").trim();
|
|
||||||
|
|
||||||
// If a thread exists and the user is NOT in it, redirect them
|
|
||||||
// Exception: allow starting a new question from the main room (with optional category/difficulty)
|
|
||||||
const threadOnlySubcommands = ["stop", "scores", "fastest"];
|
|
||||||
const isThreadOnly = threadOnlySubcommands.some((sc) => args === sc || args.startsWith(sc + " "));
|
|
||||||
if (roomThreadId && !isInTriviaThread) {
|
|
||||||
if (isThreadOnly) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Please use the trivia thread for trivia commands and answers.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Allow categories and starting new questions from the main room
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args === "stop") {
|
|
||||||
await this.handleStop(ctx);
|
|
||||||
} else if (args.startsWith("scores")) {
|
|
||||||
await this.handleScores(ctx, args.slice(6).trim());
|
|
||||||
} else if (args === "categories") {
|
|
||||||
await this.handleCategories(ctx);
|
|
||||||
} else if (args === "fastest") {
|
|
||||||
await this.handleFastest(ctx);
|
|
||||||
} else {
|
|
||||||
await this.handleTrivia(ctx, args);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTrivia(ctx: MessageContext, args: string): Promise<void> {
|
|
||||||
if (this.activeQuestions.has(ctx.roomId)) {
|
|
||||||
const thread = this.triviaThreads.get(ctx.roomId);
|
|
||||||
if (thread) {
|
|
||||||
await this.sendThreadReply(ctx.roomId, thread.eventId, ctx.eventId, "A question is already active! Answer it first.");
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "A question is already active! Answer it first.");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse category and difficulty from args
|
|
||||||
let categoryId: number | undefined;
|
|
||||||
let difficulty: string | undefined;
|
|
||||||
const difficulties = ["easy", "medium", "hard"];
|
|
||||||
|
|
||||||
if (args) {
|
|
||||||
const parts = args.split(/\s+/);
|
|
||||||
const diffPart = parts.find((p) => difficulties.includes(p.toLowerCase()));
|
|
||||||
if (diffPart) {
|
|
||||||
difficulty = diffPart.toLowerCase();
|
|
||||||
parts.splice(parts.indexOf(diffPart), 1);
|
|
||||||
}
|
|
||||||
if (parts.length > 0) {
|
|
||||||
const catInput = parts.join(" ");
|
|
||||||
const matched = fuzzyMatchCategory(catInput);
|
|
||||||
if (matched) {
|
|
||||||
categoryId = matched;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// If no category/difficulty specified and we're in an existing thread, inherit its settings
|
|
||||||
const existingThread = this.triviaThreads.get(ctx.roomId);
|
|
||||||
if (existingThread) {
|
|
||||||
if (categoryId === undefined) categoryId = existingThread.categoryId;
|
|
||||||
if (difficulty === undefined) difficulty = existingThread.difficulty;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch questions if cache is empty for this room + category + difficulty
|
|
||||||
const key = this.cacheKey(ctx.roomId, categoryId, difficulty);
|
|
||||||
const roomCache = this.questionCache.get(key);
|
|
||||||
if (!roomCache || roomCache.length === 0) {
|
|
||||||
await this.fetchQuestions(ctx.roomId, categoryId, difficulty);
|
|
||||||
}
|
|
||||||
|
|
||||||
const questions = this.questionCache.get(key);
|
|
||||||
if (!questions || questions.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Failed to fetch trivia questions. Try again later.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create or get the trivia thread
|
|
||||||
const threadEventId = await this.getOrCreateThread(ctx.roomId, categoryId, difficulty);
|
|
||||||
|
|
||||||
const question = questions.shift()!;
|
|
||||||
const decodedQuestion = decodeHtml(question.question);
|
|
||||||
const correctAnswer = decodeHtml(question.correct_answer);
|
|
||||||
const incorrectAnswers = question.incorrect_answers.map(decodeHtml);
|
|
||||||
|
|
||||||
const isBoolean = question.type === "boolean";
|
|
||||||
let options: string[];
|
|
||||||
let answerIndex: number;
|
|
||||||
let answerLetter: string;
|
|
||||||
|
|
||||||
if (isBoolean) {
|
|
||||||
options = ["True", "False"];
|
|
||||||
answerIndex = correctAnswer === "True" ? 0 : 1;
|
|
||||||
answerLetter = correctAnswer.toLowerCase();
|
|
||||||
} else {
|
|
||||||
options = shuffleArray([correctAnswer, ...incorrectAnswers]);
|
|
||||||
answerIndex = options.indexOf(correctAnswer);
|
|
||||||
answerLetter = ["a", "b", "c", "d"][answerIndex];
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const now = Math.floor(Date.now() / 1000);
|
|
||||||
const result = db.prepare(`
|
|
||||||
INSERT INTO trivia_sessions (room_id, question, answer, category, difficulty, asked_at)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(ctx.roomId, decodedQuestion, correctAnswer, question.category, question.difficulty, now);
|
|
||||||
|
|
||||||
const sessionId = Number(result.lastInsertRowid);
|
|
||||||
|
|
||||||
const timeout = setTimeout(() => {
|
|
||||||
this.handleTimeout(ctx.roomId, sessionId, correctAnswer);
|
|
||||||
}, TRIVIA_TIMEOUT_SECONDS * 1000);
|
|
||||||
|
|
||||||
this.activeQuestions.set(ctx.roomId, {
|
|
||||||
sessionId,
|
|
||||||
answer: answerLetter,
|
|
||||||
answerIndex,
|
|
||||||
options,
|
|
||||||
timeout,
|
|
||||||
askedAt: Date.now(),
|
|
||||||
startedBy: ctx.sender,
|
|
||||||
threadEventId,
|
|
||||||
});
|
|
||||||
|
|
||||||
const diffLabel = question.difficulty.charAt(0).toUpperCase() + question.difficulty.slice(1);
|
|
||||||
const lines = [`[${question.category}] (${diffLabel})`, decodedQuestion, ""];
|
|
||||||
|
|
||||||
if (isBoolean) {
|
|
||||||
lines.push("True or False?");
|
|
||||||
} else {
|
|
||||||
const labels = ["A", "B", "C", "D"];
|
|
||||||
for (let i = 0; i < options.length; i++) {
|
|
||||||
lines.push(`${labels[i]}) ${options[i]}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("", `You have ${TRIVIA_TIMEOUT_SECONDS} seconds to answer!`);
|
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleAnswer(ctx: MessageContext, active: ActiveQuestion, answer: string): Promise<void> {
|
|
||||||
const isBoolean = active.options.length === 2;
|
|
||||||
let isCorrect: boolean;
|
|
||||||
|
|
||||||
if (isBoolean) {
|
|
||||||
isCorrect = answer === active.answer;
|
|
||||||
} else {
|
|
||||||
const letterIndex = ["a", "b", "c", "d"].indexOf(answer);
|
|
||||||
isCorrect = letterIndex === active.answerIndex;
|
|
||||||
}
|
|
||||||
|
|
||||||
const elapsedMs = Date.now() - active.askedAt;
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
if (isCorrect) {
|
|
||||||
clearTimeout(active.timeout);
|
|
||||||
this.activeQuestions.delete(ctx.roomId);
|
|
||||||
|
|
||||||
const points = calculatePoints(elapsedMs);
|
|
||||||
const elapsedSec = (elapsedMs / 1000).toFixed(2);
|
|
||||||
|
|
||||||
// Update session
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE trivia_sessions SET answered_by = ?, answered_at = ?, correct = 1
|
|
||||||
WHERE id = ?
|
|
||||||
`).run(ctx.sender, Math.floor(Date.now() / 1000), active.sessionId);
|
|
||||||
|
|
||||||
// Update scores
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO trivia_scores (user_id, room_id, total_correct, total_points, total_answered, current_streak, best_streak, fastest_ms)
|
|
||||||
VALUES (?, ?, 1, ?, 1, 1, 1, ?)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
|
||||||
total_correct = total_correct + 1,
|
|
||||||
total_points = total_points + ?,
|
|
||||||
total_answered = total_answered + 1,
|
|
||||||
current_streak = current_streak + 1,
|
|
||||||
best_streak = MAX(best_streak, current_streak + 1),
|
|
||||||
fastest_ms = MIN(COALESCE(fastest_ms, ?), ?)
|
|
||||||
`).run(ctx.sender, ctx.roomId, points, elapsedMs, points, elapsedMs, elapsedMs);
|
|
||||||
|
|
||||||
// Grant XP based on points
|
|
||||||
this.xpPlugin.grantXp(ctx.sender, ctx.roomId, points, "trivia correct answer");
|
|
||||||
|
|
||||||
// React with checkmark
|
|
||||||
this.sendReact(ctx.roomId, ctx.eventId, "\u2705");
|
|
||||||
|
|
||||||
await this.sendThreadMessage(
|
|
||||||
ctx.roomId,
|
|
||||||
active.threadEventId,
|
|
||||||
`Correct! ${ctx.sender} answered in ${elapsedSec}s for ${points} points!`
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Update total_answered and reset streak
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO trivia_scores (user_id, room_id, total_correct, total_points, total_answered, current_streak, best_streak)
|
|
||||||
VALUES (?, ?, 0, 0, 1, 0, 0)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
|
||||||
total_answered = total_answered + 1,
|
|
||||||
current_streak = 0
|
|
||||||
`).run(ctx.sender, ctx.roomId);
|
|
||||||
|
|
||||||
// React with X
|
|
||||||
this.sendReact(ctx.roomId, ctx.eventId, "\u274C");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleTimeout(roomId: string, sessionId: number, correctAnswer: string): void {
|
|
||||||
const active = this.activeQuestions.get(roomId);
|
|
||||||
if (!active || active.sessionId !== sessionId) return;
|
|
||||||
|
|
||||||
this.activeQuestions.delete(roomId);
|
|
||||||
|
|
||||||
// Reset streaks for all participants who answered wrong
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`
|
|
||||||
UPDATE trivia_scores SET current_streak = 0 WHERE room_id = ?
|
|
||||||
`).run(roomId);
|
|
||||||
|
|
||||||
const threadEventId = active.threadEventId;
|
|
||||||
this.sendThreadMessage(roomId, threadEventId, `Time's up! The correct answer was: ${correctAnswer}`).catch((err) =>
|
|
||||||
logger.error(`Failed to send timeout message: ${err}`)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleStop(ctx: MessageContext): Promise<void> {
|
|
||||||
const active = this.activeQuestions.get(ctx.roomId);
|
|
||||||
const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId;
|
|
||||||
if (!active) {
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, "No active trivia question to stop.");
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No active trivia question to stop.");
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.isAdmin(ctx.sender) && ctx.sender !== active.startedBy) {
|
|
||||||
await this.sendThreadReply(ctx.roomId, active.threadEventId, ctx.eventId, "Only an admin or the question starter can stop an active question.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
clearTimeout(active.timeout);
|
|
||||||
this.activeQuestions.delete(ctx.roomId);
|
|
||||||
await this.sendThreadMessage(ctx.roomId, active.threadEventId, "Trivia question cancelled.");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleScores(ctx: MessageContext, args: string): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId;
|
|
||||||
|
|
||||||
if (args === "month") {
|
|
||||||
// Current month scores
|
|
||||||
const now = new Date();
|
|
||||||
const monthStart = Math.floor(new Date(now.getFullYear(), now.getMonth(), 1).getTime() / 1000);
|
|
||||||
|
|
||||||
const rows = db.prepare(`
|
|
||||||
SELECT answered_by AS user_id,
|
|
||||||
COUNT(*) AS total_correct,
|
|
||||||
SUM(CASE WHEN correct = 1 THEN 1 ELSE 0 END) AS wins
|
|
||||||
FROM trivia_sessions
|
|
||||||
WHERE room_id = ? AND correct = 1 AND asked_at >= ?
|
|
||||||
GROUP BY answered_by
|
|
||||||
ORDER BY wins DESC
|
|
||||||
LIMIT 10
|
|
||||||
`).all(ctx.roomId, monthStart) as { user_id: string; total_correct: number; wins: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
const msg = "No trivia scores this month yet.";
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, msg);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, msg);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — ${r.wins} correct`);
|
|
||||||
const text = `Trivia Leaderboard (This Month):\n${lines.join("\n")}`;
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, text);
|
|
||||||
} else {
|
|
||||||
await this.sendMessage(ctx.roomId, text);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.startsWith("@")) {
|
|
||||||
// Individual user scores
|
|
||||||
const targetUser = args.split(/\s/)[0];
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT * FROM trivia_scores WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as {
|
|
||||||
total_correct: number;
|
|
||||||
total_points: number;
|
|
||||||
total_answered: number;
|
|
||||||
current_streak: number;
|
|
||||||
best_streak: number;
|
|
||||||
fastest_ms: number | null;
|
|
||||||
} | undefined;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
const msg = `No trivia data found for ${targetUser}.`;
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, msg);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, msg);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const fastestSec = row.fastest_ms != null ? (row.fastest_ms / 1000).toFixed(2) + "s" : "N/A";
|
|
||||||
const accuracy = row.total_answered > 0
|
|
||||||
? ((row.total_correct / row.total_answered) * 100).toFixed(1) + "%"
|
|
||||||
: "N/A";
|
|
||||||
|
|
||||||
const statsText = `Trivia Stats for ${targetUser}:\n` +
|
|
||||||
`Correct: ${row.total_correct}/${row.total_answered} (${accuracy})\n` +
|
|
||||||
`Points: ${row.total_points}\n` +
|
|
||||||
`Streak: ${row.current_streak} (Best: ${row.best_streak})\n` +
|
|
||||||
`Fastest: ${fastestSec}`;
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, statsText);
|
|
||||||
} else {
|
|
||||||
await this.sendMessage(ctx.roomId, statsText);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// General leaderboard
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT user_id, total_correct, total_points, best_streak
|
|
||||||
FROM trivia_scores WHERE room_id = ?
|
|
||||||
ORDER BY total_points DESC LIMIT 10`
|
|
||||||
)
|
|
||||||
.all(ctx.roomId) as { user_id: string; total_correct: number; total_points: number; best_streak: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
const msg = "No trivia scores yet.";
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, msg);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, msg);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map(
|
|
||||||
(r, i) => `${i + 1}. ${r.user_id} — ${r.total_points} pts (${r.total_correct} correct, best streak: ${r.best_streak})`
|
|
||||||
);
|
|
||||||
const text = `Trivia Leaderboard:\n${lines.join("\n")}`;
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, text);
|
|
||||||
} else {
|
|
||||||
await this.sendMessage(ctx.roomId, text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleCategories(ctx: MessageContext): Promise<void> {
|
|
||||||
const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId;
|
|
||||||
const lines = ["Available Trivia Categories:"];
|
|
||||||
for (const [id, name] of Object.entries(CATEGORY_MAP)) {
|
|
||||||
lines.push(` ${id}. ${name}`);
|
|
||||||
}
|
|
||||||
const text = lines.join("\n");
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, text);
|
|
||||||
} else {
|
|
||||||
await this.sendMessage(ctx.roomId, text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleFastest(ctx: MessageContext): Promise<void> {
|
|
||||||
const threadEventId = this.triviaThreads.get(ctx.roomId)?.eventId;
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(
|
|
||||||
`SELECT user_id, fastest_ms
|
|
||||||
FROM trivia_scores WHERE room_id = ? AND fastest_ms IS NOT NULL
|
|
||||||
ORDER BY fastest_ms ASC LIMIT 10`
|
|
||||||
)
|
|
||||||
.all(ctx.roomId) as { user_id: string; fastest_ms: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
const msg = "No fastest answer data yet.";
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadReply(ctx.roomId, threadEventId, ctx.eventId, msg);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, msg);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const text = `Fastest Trivia Answers:\n${rows.map(
|
|
||||||
(r, i) => `${i + 1}. ${r.user_id} — ${(r.fastest_ms / 1000).toFixed(2)}s`
|
|
||||||
).join("\n")}`;
|
|
||||||
if (threadEventId) {
|
|
||||||
await this.sendThreadMessage(ctx.roomId, threadEventId, text);
|
|
||||||
} else {
|
|
||||||
await this.sendMessage(ctx.roomId, text);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,149 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import { parse as parseHtml } from "node-html-parser";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const URL_REGEX = /https?:\/\/[^\s<>]+/g;
|
|
||||||
const MEDIA_EXT = /\.(jpg|jpeg|png|gif|webp|mp4|webm|mp3|wav|pdf|zip|tar|gz)$/i;
|
|
||||||
const CACHE_TTL = 86400; // 24 hours in seconds
|
|
||||||
const FETCH_TIMEOUT = 3000; // 3 seconds
|
|
||||||
const ENABLED = process.env.FEATURE_URL_PREVIEW === "true";
|
|
||||||
|
|
||||||
export class UrlsPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
this.ensureTable();
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "urls";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
private ensureTable(): void {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
db.exec(`
|
|
||||||
CREATE TABLE IF NOT EXISTS url_cache (
|
|
||||||
url TEXT PRIMARY KEY,
|
|
||||||
title TEXT,
|
|
||||||
description TEXT,
|
|
||||||
cached_at INTEGER
|
|
||||||
)
|
|
||||||
`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug(`Failed to create url_cache table: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!ENABLED) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const matches = ctx.body.match(URL_REGEX);
|
|
||||||
if (!matches || matches.length === 0) return;
|
|
||||||
|
|
||||||
// Only process the first URL to avoid spam
|
|
||||||
const url = matches[0];
|
|
||||||
|
|
||||||
// Skip direct media files
|
|
||||||
if (MEDIA_EXT.test(url)) return;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Check cache
|
|
||||||
const cached = db
|
|
||||||
.prepare(`SELECT title, description, cached_at FROM url_cache WHERE url = ? AND cached_at > (unixepoch() - ?)`)
|
|
||||||
.get(url, CACHE_TTL) as { title: string | null; description: string | null; cached_at: number } | undefined;
|
|
||||||
|
|
||||||
let title: string | null = null;
|
|
||||||
let description: string | null = null;
|
|
||||||
|
|
||||||
if (cached) {
|
|
||||||
// If title is null, a previous fetch failed — skip
|
|
||||||
if (cached.title === null) return;
|
|
||||||
title = cached.title;
|
|
||||||
description = cached.description;
|
|
||||||
} else {
|
|
||||||
// Fetch the URL
|
|
||||||
const result = await this.fetchUrl(url);
|
|
||||||
if (!result) {
|
|
||||||
// Cache the failure
|
|
||||||
db.prepare(`INSERT OR REPLACE INTO url_cache (url, title, description, cached_at) VALUES (?, NULL, NULL, unixepoch())`).run(url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
title = result.title;
|
|
||||||
description = result.description;
|
|
||||||
|
|
||||||
// Cache result
|
|
||||||
db.prepare(`INSERT OR REPLACE INTO url_cache (url, title, description, cached_at) VALUES (?, ?, ?, unixepoch())`).run(
|
|
||||||
url,
|
|
||||||
title,
|
|
||||||
description
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!title) return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip if the message body already contains the title text
|
|
||||||
if (title && ctx.body.toLowerCase().includes(title.toLowerCase())) return;
|
|
||||||
|
|
||||||
// Build reply
|
|
||||||
let reply = `\u{1F517} ${title}`;
|
|
||||||
if (description) {
|
|
||||||
const truncated = description.length > 200 ? description.slice(0, 200) + "..." : description;
|
|
||||||
reply += `\n${truncated}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, reply);
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug(`URL preview error: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async fetchUrl(url: string): Promise<{ title: string | null; description: string | null } | null> {
|
|
||||||
try {
|
|
||||||
const controller = new AbortController();
|
|
||||||
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(url, {
|
|
||||||
signal: controller.signal,
|
|
||||||
headers: {
|
|
||||||
"User-Agent": "Freebee Bot/1.0",
|
|
||||||
},
|
|
||||||
redirect: "follow",
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) return null;
|
|
||||||
|
|
||||||
const contentType = res.headers.get("content-type") ?? "";
|
|
||||||
if (!contentType.includes("text/html")) return null;
|
|
||||||
|
|
||||||
const html = await res.text();
|
|
||||||
const root = parseHtml(html);
|
|
||||||
|
|
||||||
// Extract title: og:title -> <title>
|
|
||||||
const ogTitle = root.querySelector('meta[property="og:title"]')?.getAttribute("content") ?? null;
|
|
||||||
const titleTag = root.querySelector("title")?.text ?? null;
|
|
||||||
const title = ogTitle || titleTag || null;
|
|
||||||
|
|
||||||
// Extract description: og:description -> meta[name="description"]
|
|
||||||
const ogDesc = root.querySelector('meta[property="og:description"]')?.getAttribute("content") ?? null;
|
|
||||||
const metaDesc = root.querySelector('meta[name="description"]')?.getAttribute("content") ?? null;
|
|
||||||
const description = ogDesc || metaDesc || null;
|
|
||||||
|
|
||||||
return { title, description };
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timeout);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.debug(`Failed to fetch URL ${url}: ${err}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,473 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext, ReactionContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import { DateTime, IANAZone } from "luxon";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
// Common city → IANA timezone mapping
|
|
||||||
const CITY_TIMEZONE_MAP: Record<string, string> = {
|
|
||||||
"new york": "America/New_York",
|
|
||||||
"nyc": "America/New_York",
|
|
||||||
"los angeles": "America/Los_Angeles",
|
|
||||||
"la": "America/Los_Angeles",
|
|
||||||
"chicago": "America/Chicago",
|
|
||||||
"denver": "America/Denver",
|
|
||||||
"phoenix": "America/Phoenix",
|
|
||||||
"london": "Europe/London",
|
|
||||||
"paris": "Europe/Paris",
|
|
||||||
"berlin": "Europe/Berlin",
|
|
||||||
"tokyo": "Asia/Tokyo",
|
|
||||||
"sydney": "Australia/Sydney",
|
|
||||||
"melbourne": "Australia/Melbourne",
|
|
||||||
"auckland": "Pacific/Auckland",
|
|
||||||
"toronto": "America/Toronto",
|
|
||||||
"vancouver": "America/Vancouver",
|
|
||||||
"mumbai": "Asia/Kolkata",
|
|
||||||
"delhi": "Asia/Kolkata",
|
|
||||||
"dubai": "Asia/Dubai",
|
|
||||||
"singapore": "Asia/Singapore",
|
|
||||||
"hong kong": "Asia/Hong_Kong",
|
|
||||||
"seoul": "Asia/Seoul",
|
|
||||||
"beijing": "Asia/Shanghai",
|
|
||||||
"shanghai": "Asia/Shanghai",
|
|
||||||
"moscow": "Europe/Moscow",
|
|
||||||
"istanbul": "Europe/Istanbul",
|
|
||||||
"cairo": "Africa/Cairo",
|
|
||||||
"johannesburg": "Africa/Johannesburg",
|
|
||||||
"sao paulo": "America/Sao_Paulo",
|
|
||||||
"mexico city": "America/Mexico_City",
|
|
||||||
"amsterdam": "Europe/Amsterdam",
|
|
||||||
"rome": "Europe/Rome",
|
|
||||||
"madrid": "Europe/Madrid",
|
|
||||||
"lisbon": "Europe/Lisbon",
|
|
||||||
"bangkok": "Asia/Bangkok",
|
|
||||||
"jakarta": "Asia/Jakarta",
|
|
||||||
"manila": "Asia/Manila",
|
|
||||||
"kuala lumpur": "Asia/Kuala_Lumpur",
|
|
||||||
"taipei": "Asia/Taipei",
|
|
||||||
"oslo": "Europe/Oslo",
|
|
||||||
"stockholm": "Europe/Stockholm",
|
|
||||||
"helsinki": "Europe/Helsinki",
|
|
||||||
"warsaw": "Europe/Warsaw",
|
|
||||||
"prague": "Europe/Prague",
|
|
||||||
"vienna": "Europe/Vienna",
|
|
||||||
"zurich": "Europe/Zurich",
|
|
||||||
"athens": "Europe/Athens",
|
|
||||||
"honolulu": "Pacific/Honolulu",
|
|
||||||
"anchorage": "America/Anchorage",
|
|
||||||
"hawaii": "Pacific/Honolulu",
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Try to resolve a city name to an IANA timezone by checking Region/City patterns. */
|
|
||||||
function guessIANAZone(city: string): string | null {
|
|
||||||
const formatted = city
|
|
||||||
.split(/[\s-]+/)
|
|
||||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase())
|
|
||||||
.join("_");
|
|
||||||
|
|
||||||
const regions = ["Europe", "America", "Asia", "Africa", "Pacific", "Australia", "Atlantic", "Indian", "Arctic"];
|
|
||||||
for (const region of regions) {
|
|
||||||
const candidate = `${region}/${formatted}`;
|
|
||||||
if (IANAZone.isValidZone(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class UserPlugin extends Plugin {
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "user";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "settz", description: "Set your timezone", usage: "!settz <city|IANA>" },
|
|
||||||
{ name: "mytz", description: "Show your timezone" },
|
|
||||||
{ name: "timezone list", description: "World clock for everyone in the room" },
|
|
||||||
{ name: "quote", description: "Random starred quote", usage: "!quote [@user]" },
|
|
||||||
{ name: "np", description: "Now playing", usage: "!np [game|@user|list]" },
|
|
||||||
{ name: "backlog", description: "Game backlog", usage: "!backlog [add|list|random|done] [game]" },
|
|
||||||
{ name: "watch", description: "Keyword DM alert", usage: "!watch <keyword>" },
|
|
||||||
{ name: "watching", description: "List your keyword watches" },
|
|
||||||
{ name: "unwatch", description: "Remove a keyword watch", usage: "!unwatch <keyword|id>" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
// Passive: check keyword watches
|
|
||||||
this.checkKeywordWatches(ctx);
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "timezone list")) {
|
|
||||||
await this.handleTimezoneList(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "settz")) {
|
|
||||||
await this.handleSetTz(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "mytz")) {
|
|
||||||
await this.handleMyTz(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "quote")) {
|
|
||||||
await this.handleQuote(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "np")) {
|
|
||||||
await this.handleNp(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "backlog")) {
|
|
||||||
await this.handleBacklog(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "unwatch")) {
|
|
||||||
await this.handleUnwatch(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "watching")) {
|
|
||||||
await this.handleWatching(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "watch")) {
|
|
||||||
await this.handleWatch(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onReaction(ctx: ReactionContext): Promise<void> {
|
|
||||||
// Star reactions save quotes
|
|
||||||
if (ctx.reactionKey === "\u2B50" || ctx.reactionKey === "\u2B50\uFE0F") {
|
|
||||||
await this.saveQuote(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkKeywordWatches(ctx: MessageContext): void {
|
|
||||||
const db = getDb();
|
|
||||||
const watches = db
|
|
||||||
.prepare(`SELECT user_id, keyword FROM keyword_watches WHERE room_id = ?`)
|
|
||||||
.all(ctx.roomId) as { user_id: string; keyword: string }[];
|
|
||||||
|
|
||||||
const bodyLower = ctx.body.toLowerCase();
|
|
||||||
|
|
||||||
for (const watch of watches) {
|
|
||||||
if (watch.user_id === ctx.sender) continue; // Don't alert on own messages
|
|
||||||
if (bodyLower.includes(watch.keyword.toLowerCase())) {
|
|
||||||
this.sendDm(
|
|
||||||
watch.user_id,
|
|
||||||
`Keyword alert "${watch.keyword}" triggered by ${ctx.sender} in ${ctx.roomId}:\n${ctx.body.slice(0, 200)}`
|
|
||||||
).catch((err) => logger.error(`Failed keyword DM: ${err}`));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private resolveTimezone(input: string): string | null {
|
|
||||||
const lower = input.toLowerCase().trim();
|
|
||||||
|
|
||||||
// Check city map first
|
|
||||||
if (CITY_TIMEZONE_MAP[lower]) {
|
|
||||||
return CITY_TIMEZONE_MAP[lower];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try direct IANA validation
|
|
||||||
if (IANAZone.isValidZone(input.trim())) {
|
|
||||||
return input.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try guessing from IANA zone database (e.g., "Lisbon" → "Europe/Lisbon")
|
|
||||||
return guessIANAZone(lower);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleTimezoneList(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT user_id, timezone FROM users WHERE room_id = ? AND timezone IS NOT NULL`)
|
|
||||||
.all(ctx.roomId) as { user_id: string; timezone: string }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No one in this room has set a timezone yet. Use !settz to set yours.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group users by timezone, sort by UTC offset
|
|
||||||
const groups = new Map<string, string[]>();
|
|
||||||
for (const row of rows) {
|
|
||||||
const list = groups.get(row.timezone) ?? [];
|
|
||||||
list.push(row.user_id);
|
|
||||||
groups.set(row.timezone, list);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = [...groups.entries()].map(([tz, users]) => {
|
|
||||||
const dt = DateTime.now().setZone(tz);
|
|
||||||
return { tz, users, offset: dt.offset, time: dt.toFormat("HH:mm (EEE)") };
|
|
||||||
}).sort((a, b) => a.offset - b.offset);
|
|
||||||
|
|
||||||
const lines = entries.map((e) => {
|
|
||||||
const userList = e.users.join(", ");
|
|
||||||
return `${e.time} — ${e.tz}\n ${userList}`;
|
|
||||||
});
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, `World Clock:\n\n${lines.join("\n\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleSetTz(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "settz");
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !settz <city or IANA timezone>\nExamples: !settz Tokyo, !settz America/New_York");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const tz = this.resolveTimezone(args);
|
|
||||||
if (!tz) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Unknown timezone "${args}". Try a city name or IANA zone (e.g., America/New_York).`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO users (user_id, room_id, timezone)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET timezone = ?
|
|
||||||
`).run(ctx.sender, ctx.roomId, tz, tz);
|
|
||||||
|
|
||||||
const now = DateTime.now().setZone(tz);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Timezone set to ${tz}. Your current time: ${now.toFormat("HH:mm (ZZZZZ)")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleMyTz(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT timezone FROM users WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(ctx.sender, ctx.roomId) as { timezone: string | null } | undefined;
|
|
||||||
|
|
||||||
if (!row?.timezone) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "You haven't set a timezone yet. Use !settz <city or IANA zone>.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = DateTime.now().setZone(row.timezone);
|
|
||||||
await this.sendMessage(ctx.roomId, `${ctx.sender}: ${row.timezone} — ${now.toFormat("HH:mm (ZZZZZ)")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveQuote(ctx: ReactionContext): Promise<void> {
|
|
||||||
try {
|
|
||||||
const event = await this.client.getEvent(ctx.roomId, ctx.targetEventId);
|
|
||||||
const body = event?.content?.body;
|
|
||||||
if (!body) return;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
// Avoid duplicate quotes for the same event
|
|
||||||
const existing = db
|
|
||||||
.prepare(`SELECT id FROM quotes WHERE event_id = ? AND room_id = ?`)
|
|
||||||
.get(ctx.targetEventId, ctx.roomId);
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
db.prepare(`INSERT INTO quotes (user_id, room_id, message, quoted_by, event_id) VALUES (?, ?, ?, ?, ?)`).run(
|
|
||||||
event.sender,
|
|
||||||
ctx.roomId,
|
|
||||||
body,
|
|
||||||
ctx.sender,
|
|
||||||
ctx.targetEventId
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(`Quote saved from ${event.sender} by ${ctx.sender}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to save quote: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleQuote(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "quote");
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
let row: { user_id: string; message: string } | undefined;
|
|
||||||
if (args.startsWith("@")) {
|
|
||||||
const targetUser = args.split(/\s/)[0];
|
|
||||||
row = db
|
|
||||||
.prepare(`SELECT user_id, message FROM quotes WHERE user_id = ? AND room_id = ? ORDER BY RANDOM() LIMIT 1`)
|
|
||||||
.get(targetUser, ctx.roomId) as typeof row;
|
|
||||||
} else {
|
|
||||||
row = db
|
|
||||||
.prepare(`SELECT user_id, message FROM quotes WHERE room_id = ? ORDER BY RANDOM() LIMIT 1`)
|
|
||||||
.get(ctx.roomId) as typeof row;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No quotes found. Star a message with a reaction to save it!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, `"${row.message}" — ${row.user_id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleNp(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "np");
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
if (!args) {
|
|
||||||
// Show current now-playing
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT game FROM now_playing WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(ctx.sender, ctx.roomId) as { game: string } | undefined;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "You're not playing anything. Use !np <game> to set it.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.sendMessage(ctx.roomId, `${ctx.sender} is playing: ${row.game}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args.startsWith("@")) {
|
|
||||||
const targetUser = args.split(/\s/)[0];
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT game FROM now_playing WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { game: string } | undefined;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendMessage(ctx.roomId, `${targetUser} isn't playing anything.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.sendMessage(ctx.roomId, `${targetUser} is playing: ${row.game}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (args === "list") {
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT user_id, game FROM now_playing WHERE room_id = ?`)
|
|
||||||
.all(ctx.roomId) as { user_id: string; game: string }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Nobody is playing anything right now.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((r) => `${r.user_id}: ${r.game}`);
|
|
||||||
await this.sendMessage(ctx.roomId, `Now Playing:\n${lines.join("\n")}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set now playing
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO now_playing (user_id, room_id, game)
|
|
||||||
VALUES (?, ?, ?)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET game = ?, started_at = datetime('now')
|
|
||||||
`).run(ctx.sender, ctx.roomId, args, args);
|
|
||||||
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Now playing: ${args}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleBacklog(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "backlog");
|
|
||||||
const parts = args.split(/\s+/);
|
|
||||||
const subcommand = parts[0]?.toLowerCase() || "list";
|
|
||||||
const game = parts.slice(1).join(" ");
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
switch (subcommand) {
|
|
||||||
case "add": {
|
|
||||||
if (!game) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !backlog add <game name>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
db.prepare(`
|
|
||||||
INSERT OR IGNORE INTO backlog (user_id, room_id, game) VALUES (?, ?, ?)
|
|
||||||
`).run(ctx.sender, ctx.roomId, game);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Added "${game}" to your backlog.`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "done": {
|
|
||||||
if (!game) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !backlog done <game name>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const result = db
|
|
||||||
.prepare(`UPDATE backlog SET completed = 1, completed_at = datetime('now') WHERE user_id = ? AND room_id = ? AND game = ? AND completed = 0`)
|
|
||||||
.run(ctx.sender, ctx.roomId, game);
|
|
||||||
if (result.changes > 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Marked "${game}" as completed!`);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `"${game}" not found in your active backlog.`);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "random": {
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT game FROM backlog WHERE user_id = ? AND room_id = ? AND completed = 0 ORDER BY RANDOM() LIMIT 1`)
|
|
||||||
.get(ctx.sender, ctx.roomId) as { game: string } | undefined;
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your backlog is empty!");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await this.sendMessage(ctx.roomId, `Random pick from your backlog: ${row.game}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case "list":
|
|
||||||
default: {
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT game, completed FROM backlog WHERE user_id = ? AND room_id = ? ORDER BY completed ASC, added_at DESC`)
|
|
||||||
.all(ctx.sender, ctx.roomId) as { game: string; completed: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Your backlog is empty. Use !backlog add <game> to start.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((r) => `${r.completed ? "[done]" : "[ ]"} ${r.game}`);
|
|
||||||
await this.sendMessage(ctx.roomId, `${ctx.sender}'s backlog:\n${lines.join("\n")}`);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWatch(ctx: MessageContext): Promise<void> {
|
|
||||||
const keyword = this.getArgs(ctx.body, "watch").trim();
|
|
||||||
if (!keyword) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !watch <keyword>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
try {
|
|
||||||
db.prepare(`INSERT INTO keyword_watches (user_id, room_id, keyword) VALUES (?, ?, ?)`).run(
|
|
||||||
ctx.sender,
|
|
||||||
ctx.roomId,
|
|
||||||
keyword
|
|
||||||
);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Watching for "${keyword}". You'll get a DM when someone mentions it.`);
|
|
||||||
} catch {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `You're already watching for "${keyword}".`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWatching(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT id, keyword FROM keyword_watches WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.all(ctx.sender, ctx.roomId) as { id: number; keyword: string }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "You're not watching any keywords. Use !watch <keyword> to start.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((r) => `[${r.id}] ${r.keyword}`);
|
|
||||||
await this.sendMessage(ctx.roomId, `Your keyword watches:\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleUnwatch(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "unwatch").trim();
|
|
||||||
if (!args) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Usage: !unwatch <keyword|id>");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
// Try by ID first
|
|
||||||
const idNum = parseInt(args);
|
|
||||||
let result;
|
|
||||||
if (!isNaN(idNum)) {
|
|
||||||
result = db
|
|
||||||
.prepare(`DELETE FROM keyword_watches WHERE id = ? AND user_id = ? AND room_id = ?`)
|
|
||||||
.run(idNum, ctx.sender, ctx.roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!result || result.changes === 0) {
|
|
||||||
// Try by keyword
|
|
||||||
result = db
|
|
||||||
.prepare(`DELETE FROM keyword_watches WHERE keyword = ? AND user_id = ? AND room_id = ?`)
|
|
||||||
.run(args, ctx.sender, ctx.roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.changes > 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Removed watch for "${args}".`);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No watch found matching "${args}".`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,246 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const OLLAMA_HOST = (() => {
|
|
||||||
const raw = process.env.OLLAMA_HOST ?? "";
|
|
||||||
return raw && !raw.startsWith("http") ? `http://${raw}` : raw;
|
|
||||||
})();
|
|
||||||
const OLLAMA_MODEL = process.env.OLLAMA_MODEL ?? "";
|
|
||||||
const LLM_ENABLED = OLLAMA_HOST !== "" && OLLAMA_MODEL !== "";
|
|
||||||
const OLLAMA_TIMEOUT_MS = 45_000;
|
|
||||||
const OLLAMA_NUM_CTX = 16384;
|
|
||||||
|
|
||||||
const BUFFER_CAP = 50;
|
|
||||||
const MIN_MESSAGES = 10;
|
|
||||||
const COOLDOWN_MS = 5 * 60 * 1000; // 5 minutes per room per command
|
|
||||||
|
|
||||||
interface BufferedMessage {
|
|
||||||
sender: string;
|
|
||||||
body: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class VibePlugin extends Plugin {
|
|
||||||
private buffers = new Map<string, BufferedMessage[]>();
|
|
||||||
private vibeCooldowns = new Map<string, number>();
|
|
||||||
private tldrCooldowns = new Map<string, number>();
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "vibe";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "vibe", description: "Describe the current room energy", usage: "!vibe" },
|
|
||||||
{ name: "tldr", description: "Summarize recent conversation", usage: "!tldr [n]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
// Buffer only plain text messages (skip commands for vibe/tldr)
|
|
||||||
const msgtype = ctx.event?.content?.msgtype;
|
|
||||||
if (msgtype === "m.text" && !this.isCommand(ctx.body, "vibe") && !this.isCommand(ctx.body, "tldr")) {
|
|
||||||
this.pushMessage(ctx.roomId, ctx.sender, ctx.body);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "vibe")) {
|
|
||||||
await this.handleVibe(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "tldr")) {
|
|
||||||
await this.handleTldr(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private pushMessage(roomId: string, sender: string, body: string): void {
|
|
||||||
let buf = this.buffers.get(roomId);
|
|
||||||
if (!buf) {
|
|
||||||
buf = [];
|
|
||||||
this.buffers.set(roomId, buf);
|
|
||||||
}
|
|
||||||
buf.push({ sender, body });
|
|
||||||
if (buf.length > BUFFER_CAP) {
|
|
||||||
buf.shift();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkCooldown(cooldowns: Map<string, number>, roomId: string): number | null {
|
|
||||||
const lastUsed = cooldowns.get(roomId) ?? 0;
|
|
||||||
const remaining = lastUsed + COOLDOWN_MS - Date.now();
|
|
||||||
if (remaining > 0) {
|
|
||||||
return Math.ceil(remaining / 1000);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- !vibe ---
|
|
||||||
|
|
||||||
private async handleVibe(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!LLM_ENABLED) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "LLM features are not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cooldownSecs = this.checkCooldown(this.vibeCooldowns, ctx.roomId);
|
|
||||||
if (cooldownSecs) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Vibe check on cooldown. Try again in ${cooldownSecs}s.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const buf = this.buffers.get(ctx.roomId);
|
|
||||||
if (!buf || buf.length < MIN_MESSAGES) {
|
|
||||||
const have = buf?.length ?? 0;
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Not enough recent context yet (${have}/${MIN_MESSAGES} messages). Keep chatting.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.vibeCooldowns.set(ctx.roomId, Date.now());
|
|
||||||
|
|
||||||
try {
|
|
||||||
const vibe = await this.generateVibe(buf);
|
|
||||||
if (vibe) {
|
|
||||||
await this.sendMessage(ctx.roomId, vibe);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't read the room. Try again later.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Vibe generation failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't read the room. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async generateVibe(messages: BufferedMessage[]): Promise<string | null> {
|
|
||||||
const transcript = messages.map((m) => `${m.sender}: ${m.body}`).join("\n");
|
|
||||||
|
|
||||||
const prompt = `You are Freebee, a snarky but affectionate chat bot in a private friend group. Someone just asked you to read the room and describe the current vibe.
|
|
||||||
|
|
||||||
Below is a transcript of the last ${messages.length} messages. Describe the room's current energy in 1-2 sentences. Be creative, funny, and specific to what's actually being discussed. Think of it like a weather report for the chat's mood.
|
|
||||||
|
|
||||||
Examples of the tone we want:
|
|
||||||
- "Chaotic gremlin energy with undertones of unresolved technical debt."
|
|
||||||
- "Three people passionately disagreeing about something none of them will remember tomorrow."
|
|
||||||
- "Suspiciously wholesome. Someone's about to ruin it."
|
|
||||||
- "One person carrying the entire conversation while everyone else lurks in silence."
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- 1-2 sentences max. Short and punchy.
|
|
||||||
- Reference actual topics or dynamics from the transcript, not generic vibes.
|
|
||||||
- Do NOT name specific users. Describe roles/dynamics instead ("someone", "one person", "half the room").
|
|
||||||
- Do NOT use bullet points or formatting. Just flowing text.
|
|
||||||
- Do NOT start with "The vibe is" or "Current vibe:". Just describe it directly.
|
|
||||||
|
|
||||||
Transcript:
|
|
||||||
${transcript}
|
|
||||||
|
|
||||||
Describe the vibe now. Raw text only.`;
|
|
||||||
|
|
||||||
return this.callOllama(prompt, "vibe");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- !tldr ---
|
|
||||||
|
|
||||||
private async handleTldr(ctx: MessageContext): Promise<void> {
|
|
||||||
if (!LLM_ENABLED) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "LLM features are not configured.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cooldownSecs = this.checkCooldown(this.tldrCooldowns, ctx.roomId);
|
|
||||||
if (cooldownSecs) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `TLDR on cooldown. Try again in ${cooldownSecs}s.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const args = this.getArgs(ctx.body, "tldr").trim();
|
|
||||||
let count = BUFFER_CAP;
|
|
||||||
if (args) {
|
|
||||||
const parsed = parseInt(args, 10);
|
|
||||||
if (!isNaN(parsed) && parsed > 0) {
|
|
||||||
count = Math.min(parsed, BUFFER_CAP);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const buf = this.buffers.get(ctx.roomId);
|
|
||||||
if (!buf || buf.length < MIN_MESSAGES) {
|
|
||||||
const have = buf?.length ?? 0;
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `Not enough recent context yet (${have}/${MIN_MESSAGES} messages). Keep chatting.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const messages = buf.slice(-count);
|
|
||||||
this.tldrCooldowns.set(ctx.roomId, Date.now());
|
|
||||||
|
|
||||||
try {
|
|
||||||
const summary = await this.generateTldr(messages);
|
|
||||||
if (summary) {
|
|
||||||
await this.sendMessage(ctx.roomId, summary);
|
|
||||||
} else {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't summarize. Try again later.");
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`TLDR generation failed: ${err}`);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Couldn't summarize. Try again later.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async generateTldr(messages: BufferedMessage[]): Promise<string | null> {
|
|
||||||
const transcript = messages.map((m) => `${m.sender}: ${m.body}`).join("\n");
|
|
||||||
|
|
||||||
const prompt = `You are Freebee, a helpful chat bot in a private friend group. Someone just asked for a summary of the recent conversation so they can catch up.
|
|
||||||
|
|
||||||
Below is a transcript of the last ${messages.length} messages. Summarize what happened in a concise, easy-to-scan format.
|
|
||||||
|
|
||||||
Rules:
|
|
||||||
- Start with "TLDR:" followed by the summary.
|
|
||||||
- Group by topic/thread if the conversation covered multiple subjects.
|
|
||||||
- Keep each topic to 1 sentence.
|
|
||||||
- Use casual, natural language — not corporate meeting notes.
|
|
||||||
- You MAY reference usernames since this is a summary, not a vibe check.
|
|
||||||
- Keep the entire summary under 500 characters.
|
|
||||||
- No bullet points — use short flowing sentences separated by line breaks.
|
|
||||||
|
|
||||||
Transcript:
|
|
||||||
${transcript}
|
|
||||||
|
|
||||||
Write the summary now. Raw text only.`;
|
|
||||||
|
|
||||||
return this.callOllama(prompt, "tldr");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- shared ---
|
|
||||||
|
|
||||||
private async callOllama(prompt: string, label: string): Promise<string | null> {
|
|
||||||
const res = await fetch(`${OLLAMA_HOST}/api/generate`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify({
|
|
||||||
model: OLLAMA_MODEL,
|
|
||||||
prompt,
|
|
||||||
stream: false,
|
|
||||||
options: { num_ctx: OLLAMA_NUM_CTX, temperature: label === "tldr" ? 0.3 : 0.9 },
|
|
||||||
}),
|
|
||||||
signal: AbortSignal.timeout(OLLAMA_TIMEOUT_MS),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Ollama API returned ${res.status} for ${label}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as { response: string };
|
|
||||||
let text = data.response?.trim();
|
|
||||||
if (!text) return null;
|
|
||||||
|
|
||||||
// Clean up LLM quirks
|
|
||||||
text = text.replace(/^["']|["']$/g, "");
|
|
||||||
text = text.replace(/^#+\s*/gm, "");
|
|
||||||
text = text.replace(/\*\*/g, "");
|
|
||||||
|
|
||||||
if (label === "vibe") {
|
|
||||||
text = `Vibe check: ${text}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
return text;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext, PluginRegistry } from "./base";
|
|
||||||
import { XpPlugin } from "./xp";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
export class WelcomePlugin extends Plugin {
|
|
||||||
private xpPlugin: XpPlugin;
|
|
||||||
private registry: PluginRegistry;
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin, registry: PluginRegistry) {
|
|
||||||
super(client);
|
|
||||||
this.xpPlugin = xpPlugin;
|
|
||||||
this.registry = registry;
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "welcome";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [{ name: "help", description: "Full command list (sent as DM)" }];
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
// Passive: welcome new users before command handling
|
|
||||||
this.handleWelcome(ctx);
|
|
||||||
|
|
||||||
if (this.isCommand(ctx.body, "help")) {
|
|
||||||
await this.handleHelp(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handleWelcome(ctx: MessageContext): void {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Check if we've already welcomed this user in ANY room
|
|
||||||
const existing = db
|
|
||||||
.prepare(`SELECT id FROM achievements WHERE user_id = ? AND achievement_key = 'welcome_wagon'`)
|
|
||||||
.get(ctx.sender);
|
|
||||||
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
// New user — insert welcome_wagon achievement (tied to this room for the record)
|
|
||||||
db.prepare(`INSERT OR IGNORE INTO achievements (user_id, room_id, achievement_key) VALUES (?, ?, ?)`).run(
|
|
||||||
ctx.sender,
|
|
||||||
ctx.roomId,
|
|
||||||
"welcome_wagon"
|
|
||||||
);
|
|
||||||
|
|
||||||
// Grant 25 XP
|
|
||||||
this.xpPlugin.grantXp(ctx.sender, ctx.roomId, 25, "welcome");
|
|
||||||
|
|
||||||
// Post welcome message (fire-and-forget)
|
|
||||||
const welcomeText =
|
|
||||||
`Welcome to the community, ${ctx.sender}!\n\n` +
|
|
||||||
`You've got 25 XP to start. Here's a taste of what you can do:\n` +
|
|
||||||
`!rank !hltb <game> !remindme 1h <msg> !trivia !time <city>\n\n` +
|
|
||||||
`Type !help for the full command list (sent as a DM). Have fun!`;
|
|
||||||
|
|
||||||
this.sendMessage(ctx.roomId, welcomeText).catch((err) => {
|
|
||||||
logger.error(`Failed to send welcome message: ${err}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
logger.debug(`Welcomed new user ${ctx.sender} in ${ctx.roomId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleHelp(ctx: MessageContext): Promise<void> {
|
|
||||||
const allCommands = this.registry.getCommands();
|
|
||||||
const isAdmin = this.isAdmin(ctx.sender);
|
|
||||||
|
|
||||||
const sections: string[] = [];
|
|
||||||
|
|
||||||
for (const group of allCommands) {
|
|
||||||
const visibleCommands = group.commands.filter((cmd) => isAdmin || !cmd.adminOnly);
|
|
||||||
if (visibleCommands.length === 0) continue;
|
|
||||||
|
|
||||||
const lines = visibleCommands.map((cmd) => ` !${cmd.name} — ${cmd.description}`);
|
|
||||||
sections.push(`[${group.plugin}]\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const helpText = `Freebee Commands\n\n${sections.join("\n\n")}`;
|
|
||||||
|
|
||||||
await this.sendDm(ctx.sender, helpText);
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "Command list sent to your DMs!");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,229 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { XpPlugin } from "./xp";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const WOTD_BONUS_XP = 25;
|
|
||||||
|
|
||||||
export class WotdPlugin extends Plugin {
|
|
||||||
private xpPlugin: XpPlugin;
|
|
||||||
private todaysWord: string | null = null;
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient, xpPlugin: XpPlugin) {
|
|
||||||
super(client);
|
|
||||||
this.xpPlugin = xpPlugin;
|
|
||||||
this.loadTodaysWord();
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "wotd";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "wotd", description: "Show today's Word of the Day" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadTodaysWord(): void {
|
|
||||||
try {
|
|
||||||
const db = getDb();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
// Try today first, fall back to the most recent word
|
|
||||||
let row = db
|
|
||||||
.prepare(`SELECT word FROM wotd_log WHERE date = ? LIMIT 1`)
|
|
||||||
.get(today) as { word: string } | undefined;
|
|
||||||
if (!row) {
|
|
||||||
row = db
|
|
||||||
.prepare(`SELECT word FROM wotd_log ORDER BY date DESC LIMIT 1`)
|
|
||||||
.get() as { word: string } | undefined;
|
|
||||||
}
|
|
||||||
this.todaysWord = row?.word?.toLowerCase() ?? null;
|
|
||||||
} catch {
|
|
||||||
this.todaysWord = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
if (this.isCommand(ctx.body, "wotd")) {
|
|
||||||
await this.handleWotd(ctx);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Passive: check if message contains today's word
|
|
||||||
if (this.todaysWord) {
|
|
||||||
this.checkWordUsage(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleWotd(ctx: MessageContext): Promise<void> {
|
|
||||||
const db = getDb();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// Try today first, fall back to the most recent word if today's hasn't been posted yet
|
|
||||||
let row = db
|
|
||||||
.prepare(`SELECT word, definition, example, part_of_speech, date FROM wotd_log WHERE date = ? LIMIT 1`)
|
|
||||||
.get(today) as { word: string; definition: string | null; example: string | null; part_of_speech: string | null; date: string } | undefined;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
row = db
|
|
||||||
.prepare(`SELECT word, definition, example, part_of_speech, date FROM wotd_log ORDER BY date DESC LIMIT 1`)
|
|
||||||
.get() as typeof row;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No Word of the Day yet. It will be posted at the scheduled time.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isToday = row.date === today;
|
|
||||||
const lines = [
|
|
||||||
isToday ? `Word of the Day: ${row.word}` : `Most recent Word of the Day: ${row.word} (${row.date})`,
|
|
||||||
row.part_of_speech ? `(${row.part_of_speech})` : "",
|
|
||||||
"",
|
|
||||||
row.definition ?? "No definition available.",
|
|
||||||
row.example ? `\nExample: "${row.example}"` : "",
|
|
||||||
"",
|
|
||||||
isToday
|
|
||||||
? `Use "${row.word}" in a message today for ${WOTD_BONUS_XP} bonus XP!`
|
|
||||||
: `Today's word hasn't been posted yet. Check back after the scheduled time.`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
await this.sendMessage(ctx.roomId, lines);
|
|
||||||
}
|
|
||||||
|
|
||||||
private checkWordUsage(ctx: MessageContext): void {
|
|
||||||
if (!this.todaysWord) return;
|
|
||||||
|
|
||||||
const bodyLower = ctx.body.toLowerCase();
|
|
||||||
if (!bodyLower.includes(this.todaysWord)) return;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
|
|
||||||
// Check if already awarded today
|
|
||||||
const existing = db
|
|
||||||
.prepare(`SELECT id FROM wotd_usage WHERE user_id = ? AND room_id = ? AND date = ?`)
|
|
||||||
.get(ctx.sender, ctx.roomId, today);
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
// Award bonus XP
|
|
||||||
db.prepare(`INSERT INTO wotd_usage (user_id, room_id, date, xp_awarded) VALUES (?, ?, ?, ?)`).run(
|
|
||||||
ctx.sender,
|
|
||||||
ctx.roomId,
|
|
||||||
today,
|
|
||||||
WOTD_BONUS_XP
|
|
||||||
);
|
|
||||||
|
|
||||||
this.xpPlugin.grantXp(ctx.sender, ctx.roomId, WOTD_BONUS_XP, "wotd_usage");
|
|
||||||
logger.debug(`${ctx.sender} used WOTD "${this.todaysWord}" — awarded ${WOTD_BONUS_XP} XP`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prefetch WOTD data from API and cache it in the DB.
|
|
||||||
* Called early (e.g. 00:05 UTC) so the post step has no network dependency.
|
|
||||||
*/
|
|
||||||
async prefetch(): Promise<void> {
|
|
||||||
const apiKey = process.env.WORDNIK_API_KEY;
|
|
||||||
if (!apiKey) return;
|
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const existing = db.prepare(`SELECT data FROM daily_prefetch WHERE job_name = 'wotd' AND date = ?`).get(today);
|
|
||||||
if (existing) return;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const url = `https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=${encodeURIComponent(apiKey)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Wordnik prefetch returned ${res.status}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = await res.json() as any;
|
|
||||||
db.prepare(`INSERT OR REPLACE INTO daily_prefetch (job_name, date, data) VALUES ('wotd', ?, ?)`)
|
|
||||||
.run(today, JSON.stringify(data));
|
|
||||||
logger.info(`Prefetched WOTD for ${today}: ${data.word}`);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`WOTD prefetch failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called by DailyScheduler to post the word of the day.
|
|
||||||
* Reads from prefetch cache first, falls back to live API call.
|
|
||||||
*/
|
|
||||||
async postWotd(roomIds: string[]): Promise<void> {
|
|
||||||
const apiKey = process.env.WORDNIK_API_KEY;
|
|
||||||
if (!apiKey) {
|
|
||||||
logger.warn("WORDNIK_API_KEY not set, skipping WOTD");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Check if already posted today (any room)
|
|
||||||
const existing = db.prepare(`SELECT word FROM wotd_log WHERE date = ?`).get(today) as { word: string } | undefined;
|
|
||||||
if (existing) {
|
|
||||||
this.todaysWord = existing.word.toLowerCase();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Try prefetch cache first, fall back to live API
|
|
||||||
let data: any;
|
|
||||||
const cached = db.prepare(`SELECT data FROM daily_prefetch WHERE job_name = 'wotd' AND date = ?`).get(today) as { data: string } | undefined;
|
|
||||||
if (cached) {
|
|
||||||
data = JSON.parse(cached.data);
|
|
||||||
} else {
|
|
||||||
const url = `https://api.wordnik.com/v4/words.json/wordOfTheDay?api_key=${encodeURIComponent(apiKey)}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
if (!res.ok) {
|
|
||||||
logger.warn(`Wordnik API returned ${res.status}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
data = await res.json() as any;
|
|
||||||
}
|
|
||||||
|
|
||||||
const word = data.word;
|
|
||||||
const definitions = data.definitions ?? [];
|
|
||||||
const examples = data.examples ?? [];
|
|
||||||
|
|
||||||
const definition = definitions[0]?.text ?? "No definition available.";
|
|
||||||
const partOfSpeech = definitions[0]?.partOfSpeech ?? "";
|
|
||||||
const example = examples[0]?.text ?? "";
|
|
||||||
|
|
||||||
this.todaysWord = word.toLowerCase();
|
|
||||||
|
|
||||||
const message = [
|
|
||||||
`Word of the Day: ${word}`,
|
|
||||||
partOfSpeech ? `(${partOfSpeech})` : "",
|
|
||||||
"",
|
|
||||||
definition,
|
|
||||||
example ? `\nExample: "${example}"` : "",
|
|
||||||
"",
|
|
||||||
`Use "${word}" in a message today for ${WOTD_BONUS_XP} bonus XP!`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join("\n");
|
|
||||||
|
|
||||||
for (const roomId of roomIds) {
|
|
||||||
try {
|
|
||||||
await this.sendMessage(roomId, message);
|
|
||||||
db.prepare(`
|
|
||||||
INSERT OR IGNORE INTO wotd_log (room_id, date, word, definition, example, part_of_speech)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
`).run(roomId, today, word, definition, example || null, partOfSpeech || null);
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`Failed to post WOTD to ${roomId}: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
logger.error(`WOTD fetch failed: ${err}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { IMatrixClient, Plugin, CommandDef, MessageContext } from "./base";
|
|
||||||
import { getDb } from "../db";
|
|
||||||
import { xpToLevel, xpForNextLevel, progressBar } from "../utils/parser";
|
|
||||||
import logger from "../utils/logger";
|
|
||||||
|
|
||||||
const XP_PER_MESSAGE = 10;
|
|
||||||
const XP_COOLDOWN_MS = 30_000;
|
|
||||||
|
|
||||||
const LEVEL_UP_MESSAGES = [
|
|
||||||
"🔔✨ POWER UP! {user} hit Level {level}! Bell combo complete! ✨🔔",
|
|
||||||
"🌟 {user} grabbed the golden bell — Level {level}! Speed up! 🐝💨",
|
|
||||||
"⚡ Level {level}! {user} is fully powered up! Options attached! 🛸🛸",
|
|
||||||
"🎮 {user} evolved to Level {level}! Twin attack ready! 👊👊",
|
|
||||||
"🏆 STAGE CLEAR! {user} advanced to Level {level}! 🎆🎆🎆",
|
|
||||||
"🔔🔔🔔 {user} collected enough bells for Level {level}! Shield activated! 🛡️",
|
|
||||||
"📢 Level {level} unlocked! {user} picked up the megaphone power-up! 🔊💥",
|
|
||||||
"⚡ {user} just warped to Level {level}! Laser mode engaged! 🔴🔴🔴",
|
|
||||||
"🎰 Bonus round! {user} reached Level {level}! 3-way shot acquired! 🔱",
|
|
||||||
"⚡ {user} powered through to Level {level}! Ripple laser online! 〰️💫",
|
|
||||||
];
|
|
||||||
|
|
||||||
export class XpPlugin extends Plugin {
|
|
||||||
private cooldowns = new Map<string, number>();
|
|
||||||
|
|
||||||
constructor(client: IMatrixClient) {
|
|
||||||
super(client);
|
|
||||||
}
|
|
||||||
|
|
||||||
get name() {
|
|
||||||
return "xp";
|
|
||||||
}
|
|
||||||
|
|
||||||
get commands(): CommandDef[] {
|
|
||||||
return [
|
|
||||||
{ name: "rank", description: "Show your XP, level, and progress", usage: "!rank [@user]" },
|
|
||||||
{ name: "leaderboard", description: "Top users by XP", usage: "!leaderboard [n]" },
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Public method so other plugins (rep, wotd) can grant bonus XP.
|
|
||||||
*/
|
|
||||||
grantXp(userId: string, roomId: string, amount: number, reason: string): void {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
// Grab old level before granting
|
|
||||||
const before = db.prepare(`SELECT level FROM users WHERE user_id = ? AND room_id = ?`).get(userId, roomId) as
|
|
||||||
| { level: number }
|
|
||||||
| undefined;
|
|
||||||
const oldLevel = before?.level ?? 0;
|
|
||||||
|
|
||||||
db.prepare(`
|
|
||||||
INSERT INTO users (user_id, room_id, xp, level)
|
|
||||||
VALUES (?, ?, ?, 1)
|
|
||||||
ON CONFLICT(user_id, room_id) DO UPDATE SET
|
|
||||||
xp = xp + ?,
|
|
||||||
level = ?
|
|
||||||
`).run(
|
|
||||||
userId,
|
|
||||||
roomId,
|
|
||||||
amount,
|
|
||||||
amount,
|
|
||||||
// We recalculate level from the new XP total via a subquery-style approach.
|
|
||||||
// Since better-sqlite3 is sync, we do a two-step:
|
|
||||||
0 // placeholder — we fix level right after
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fix level based on actual XP
|
|
||||||
const row = db.prepare(`SELECT xp FROM users WHERE user_id = ? AND room_id = ?`).get(userId, roomId) as
|
|
||||||
| { xp: number }
|
|
||||||
| undefined;
|
|
||||||
let newLevel = oldLevel || 1;
|
|
||||||
if (row) {
|
|
||||||
newLevel = xpToLevel(row.xp);
|
|
||||||
db.prepare(`UPDATE users SET level = ? WHERE user_id = ? AND room_id = ?`).run(newLevel, userId, roomId);
|
|
||||||
}
|
|
||||||
|
|
||||||
db.prepare(`INSERT INTO xp_log (user_id, room_id, amount, reason) VALUES (?, ?, ?, ?)`).run(
|
|
||||||
userId,
|
|
||||||
roomId,
|
|
||||||
amount,
|
|
||||||
reason
|
|
||||||
);
|
|
||||||
|
|
||||||
logger.debug(`Granted ${amount} XP to ${userId} in ${roomId}: ${reason}`);
|
|
||||||
|
|
||||||
// Announce level up
|
|
||||||
if (newLevel > oldLevel && oldLevel > 0) {
|
|
||||||
const template = LEVEL_UP_MESSAGES[Math.floor(Math.random() * LEVEL_UP_MESSAGES.length)];
|
|
||||||
const msg = template.replace("{user}", userId).replace("{level}", String(newLevel));
|
|
||||||
this.sendMessage(roomId, msg).catch((err) => {
|
|
||||||
logger.error(`Failed to announce level up: ${err}`);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async onMessage(ctx: MessageContext): Promise<void> {
|
|
||||||
// Passive XP grant with cooldown
|
|
||||||
this.handlePassiveXp(ctx.sender, ctx.roomId);
|
|
||||||
|
|
||||||
// Commands
|
|
||||||
if (this.isCommand(ctx.body, "rank")) {
|
|
||||||
await this.handleRank(ctx);
|
|
||||||
} else if (this.isCommand(ctx.body, "leaderboard")) {
|
|
||||||
await this.handleLeaderboard(ctx);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private handlePassiveXp(userId: string, roomId: string): void {
|
|
||||||
const key = `${userId}:${roomId}`;
|
|
||||||
const now = Date.now();
|
|
||||||
const lastGrant = this.cooldowns.get(key) ?? 0;
|
|
||||||
|
|
||||||
if (now - lastGrant < XP_COOLDOWN_MS) return;
|
|
||||||
|
|
||||||
this.cooldowns.set(key, now);
|
|
||||||
this.grantXp(userId, roomId, XP_PER_MESSAGE, "message");
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleRank(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "rank");
|
|
||||||
const targetUser = args.startsWith("@") ? args.split(/\s/)[0] : ctx.sender;
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const row = db
|
|
||||||
.prepare(`SELECT xp, level, rep FROM users WHERE user_id = ? AND room_id = ?`)
|
|
||||||
.get(targetUser, ctx.roomId) as { xp: number; level: number; rep: number } | undefined;
|
|
||||||
|
|
||||||
if (!row) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, `No data found for ${targetUser}.`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const progress = xpForNextLevel(row.xp);
|
|
||||||
const bar = progressBar(progress.current, progress.needed);
|
|
||||||
|
|
||||||
await this.sendMessage(
|
|
||||||
ctx.roomId,
|
|
||||||
`${targetUser}\nLevel ${row.level} | ${row.xp} XP | ${row.rep} rep\n${bar} ${progress.current}/${progress.needed} to next level`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async handleLeaderboard(ctx: MessageContext): Promise<void> {
|
|
||||||
const args = this.getArgs(ctx.body, "leaderboard");
|
|
||||||
const limit = Math.min(Math.max(parseInt(args) || 10, 1), 25);
|
|
||||||
|
|
||||||
const db = getDb();
|
|
||||||
const rows = db
|
|
||||||
.prepare(`SELECT user_id, xp, level FROM users WHERE room_id = ? ORDER BY xp DESC LIMIT ?`)
|
|
||||||
.all(ctx.roomId, limit) as { user_id: string; xp: number; level: number }[];
|
|
||||||
|
|
||||||
if (rows.length === 0) {
|
|
||||||
await this.sendReply(ctx.roomId, ctx.eventId, "No leaderboard data yet.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = rows.map((r, i) => `${i + 1}. ${r.user_id} — Lv${r.level} (${r.xp} XP)`);
|
|
||||||
await this.sendMessage(ctx.roomId, `Leaderboard (Top ${rows.length}):\n${lines.join("\n")}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import logger from "./logger";
|
|
||||||
|
|
||||||
interface LoginResponse {
|
|
||||||
access_token: string;
|
|
||||||
device_id: string;
|
|
||||||
user_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log in to the homeserver with a password and return a fresh access token.
|
|
||||||
* Reuses the existing device_id when provided so the crypto store stays valid.
|
|
||||||
*/
|
|
||||||
export async function loginWithPassword(
|
|
||||||
homeserverUrl: string,
|
|
||||||
userId: string,
|
|
||||||
password: string,
|
|
||||||
deviceId?: string
|
|
||||||
): Promise<LoginResponse> {
|
|
||||||
const localpart = userId.split(":")[0].replace(/^@/, "");
|
|
||||||
|
|
||||||
const body: Record<string, string> = {
|
|
||||||
type: "m.login.password",
|
|
||||||
user: localpart,
|
|
||||||
password,
|
|
||||||
};
|
|
||||||
if (deviceId) {
|
|
||||||
body.device_id = deviceId;
|
|
||||||
}
|
|
||||||
|
|
||||||
const res = await fetch(`${homeserverUrl}/_matrix/client/v3/login`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text();
|
|
||||||
throw new Error(`Login failed (${res.status}): ${text}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as LoginResponse;
|
|
||||||
logger.info(`Obtained new access token for device ${data.device_id}`);
|
|
||||||
return data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check whether the current access token is still valid.
|
|
||||||
*/
|
|
||||||
export async function isTokenValid(homeserverUrl: string, accessToken: string): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${homeserverUrl}/_matrix/client/v3/account/whoami`, {
|
|
||||||
headers: { Authorization: `Bearer ${accessToken}` },
|
|
||||||
});
|
|
||||||
return res.ok;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
import winston from "winston";
|
|
||||||
|
|
||||||
const logger = winston.createLogger({
|
|
||||||
level: process.env.LOG_LEVEL ?? "info",
|
|
||||||
format: winston.format.combine(
|
|
||||||
winston.format.timestamp(),
|
|
||||||
winston.format.errors({ stack: true }),
|
|
||||||
winston.format.printf(({ timestamp, level, message, stack }) => {
|
|
||||||
const msg = `${timestamp} [${level.toUpperCase()}] ${message}`;
|
|
||||||
return stack ? `${msg}\n${stack}` : msg;
|
|
||||||
})
|
|
||||||
),
|
|
||||||
transports: [
|
|
||||||
new winston.transports.Console(),
|
|
||||||
new winston.transports.File({
|
|
||||||
filename: `${process.env.DATA_DIR ?? "./data"}/freebee.log`,
|
|
||||||
maxsize: 5 * 1024 * 1024,
|
|
||||||
maxFiles: 3,
|
|
||||||
tailable: true,
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
export default logger;
|
|
||||||
@@ -1,125 +0,0 @@
|
|||||||
export interface ParsedMessage {
|
|
||||||
wordCount: number;
|
|
||||||
charCount: number;
|
|
||||||
linkCount: number;
|
|
||||||
imageCount: number;
|
|
||||||
questionCount: number;
|
|
||||||
exclamationCount: number;
|
|
||||||
emojiCount: number;
|
|
||||||
longestWord: string;
|
|
||||||
hasLongWord: boolean; // word > 15 characters
|
|
||||||
}
|
|
||||||
|
|
||||||
const URL_REGEX = /https?:\/\/[^\s]+/gi;
|
|
||||||
const EMOJI_REGEX = /[\p{Emoji_Presentation}\p{Extended_Pictographic}]/gu;
|
|
||||||
const IMAGE_EXTENSIONS = /\.(png|jpg|jpeg|gif|webp|svg|bmp)(\?[^\s]*)?$/i;
|
|
||||||
|
|
||||||
export function parseMessage(body: string): ParsedMessage {
|
|
||||||
const words = body.split(/\s+/).filter((w) => w.length > 0);
|
|
||||||
const links = body.match(URL_REGEX) ?? [];
|
|
||||||
const emojis = body.match(EMOJI_REGEX) ?? [];
|
|
||||||
const questions = (body.match(/\?/g) ?? []).length;
|
|
||||||
const exclamations = (body.match(/!/g) ?? []).length;
|
|
||||||
const images = links.filter((l) => IMAGE_EXTENSIONS.test(l)).length;
|
|
||||||
|
|
||||||
let longestWord = "";
|
|
||||||
for (const word of words) {
|
|
||||||
const cleaned = word.replace(/[^\w]/g, "");
|
|
||||||
if (cleaned.length > longestWord.length) {
|
|
||||||
longestWord = cleaned;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
wordCount: words.length,
|
|
||||||
charCount: body.length,
|
|
||||||
linkCount: links.length,
|
|
||||||
imageCount: images,
|
|
||||||
questionCount: questions,
|
|
||||||
exclamationCount: exclamations,
|
|
||||||
emojiCount: emojis.length,
|
|
||||||
longestWord,
|
|
||||||
hasLongWord: longestWord.length > 15,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// XP curve: level N requires N*100 total XP
|
|
||||||
export function levelToXp(level: number): number {
|
|
||||||
return level * 100;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function xpToLevel(xp: number): number {
|
|
||||||
return Math.max(1, Math.floor(xp / 100));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function xpForNextLevel(xp: number): { current: number; needed: number } {
|
|
||||||
const currentLevel = xpToLevel(xp);
|
|
||||||
const nextLevelXp = levelToXp(currentLevel + 1);
|
|
||||||
const currentLevelXp = levelToXp(currentLevel);
|
|
||||||
return {
|
|
||||||
current: xp - currentLevelXp,
|
|
||||||
needed: nextLevelXp - currentLevelXp,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function progressBar(current: number, total: number, length: number = 10): string {
|
|
||||||
const filled = Math.round((current / total) * length);
|
|
||||||
const empty = length - filled;
|
|
||||||
return "[" + "\u2588".repeat(filled) + "\u2591".repeat(empty) + "]";
|
|
||||||
}
|
|
||||||
|
|
||||||
const ARCHETYPES: { key: string; label: string; check: (s: ArchetypeStats) => boolean }[] = [
|
|
||||||
{ key: "chatterbox", label: "The Chatterbox", check: (s) => s.totalMessages > 1000 && s.avgWords < 10 },
|
|
||||||
{ key: "novelist", label: "The Novelist", check: (s) => s.avgWords > 40 },
|
|
||||||
{ key: "inquisitor", label: "The Inquisitor", check: (s) => s.questionRatio > 0.3 },
|
|
||||||
{ key: "linkmaster", label: "The Linkmaster", check: (s) => s.linkRatio > 0.2 },
|
|
||||||
{ key: "shutterbug", label: "The Shutterbug", check: (s) => s.imageRatio > 0.15 },
|
|
||||||
{ key: "night_owl", label: "The Night Owl", check: (s) => s.nightRatio > 0.4 },
|
|
||||||
{ key: "early_bird", label: "The Early Bird", check: (s) => s.morningRatio > 0.4 },
|
|
||||||
{ key: "enthusiast", label: "The Enthusiast", check: (s) => s.exclamationRatio > 0.3 },
|
|
||||||
{ key: "regular", label: "The Regular", check: () => true },
|
|
||||||
];
|
|
||||||
|
|
||||||
interface ArchetypeStats {
|
|
||||||
totalMessages: number;
|
|
||||||
avgWords: number;
|
|
||||||
questionRatio: number;
|
|
||||||
linkRatio: number;
|
|
||||||
imageRatio: number;
|
|
||||||
nightRatio: number;
|
|
||||||
morningRatio: number;
|
|
||||||
exclamationRatio: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function deriveArchetype(stats: {
|
|
||||||
total_messages: number;
|
|
||||||
avg_words_per_message: number;
|
|
||||||
total_questions: number;
|
|
||||||
total_links: number;
|
|
||||||
total_images: number;
|
|
||||||
total_exclamations: number;
|
|
||||||
hourly_distribution: string;
|
|
||||||
}): string {
|
|
||||||
const hourly: Record<string, number> = JSON.parse(stats.hourly_distribution || "{}");
|
|
||||||
const totalFromHourly = Object.values(hourly).reduce((a, b) => a + b, 0) || 1;
|
|
||||||
|
|
||||||
const nightMessages = [0, 1, 2, 3].reduce((sum, h) => sum + (hourly[h] ?? 0), 0);
|
|
||||||
const morningMessages = [5, 6, 7, 8, 9].reduce((sum, h) => sum + (hourly[h] ?? 0), 0);
|
|
||||||
|
|
||||||
const s: ArchetypeStats = {
|
|
||||||
totalMessages: stats.total_messages || 1,
|
|
||||||
avgWords: stats.avg_words_per_message,
|
|
||||||
questionRatio: stats.total_questions / (stats.total_messages || 1),
|
|
||||||
linkRatio: stats.total_links / (stats.total_messages || 1),
|
|
||||||
imageRatio: stats.total_images / (stats.total_messages || 1),
|
|
||||||
nightRatio: nightMessages / totalFromHourly,
|
|
||||||
morningRatio: morningMessages / totalFromHourly,
|
|
||||||
exclamationRatio: stats.total_exclamations / (stats.total_messages || 1),
|
|
||||||
};
|
|
||||||
|
|
||||||
for (const archetype of ARCHETYPES) {
|
|
||||||
if (archetype.check(s)) return archetype.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
return "The Regular";
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
/**
|
|
||||||
* LLM Classifier Worker — STUB
|
|
||||||
*
|
|
||||||
* This worker thread will handle shade classification via Ollama when the feature ships.
|
|
||||||
*
|
|
||||||
* Design notes:
|
|
||||||
* - Runs in a separate Worker thread to avoid blocking the main event loop
|
|
||||||
* - Communicates via MessageChannel (parentPort)
|
|
||||||
* - Receives: { type: "classify", id: string, text: string }
|
|
||||||
* - Returns: { type: "result", id: string, isShade: boolean, confidence: number }
|
|
||||||
* - Uses the model specified by OLLAMA_MODEL env var
|
|
||||||
* - Rate limited to prevent overwhelming the LLM host
|
|
||||||
*
|
|
||||||
* When wiring this up:
|
|
||||||
* 1. ShadePlugin creates a Worker pointing to this file
|
|
||||||
* 2. On each message, ShadePlugin posts a classify job
|
|
||||||
* 3. Worker calls Ollama API and returns the classification
|
|
||||||
* 4. ShadePlugin writes results to shade_log table
|
|
||||||
*/
|
|
||||||
|
|
||||||
// import { parentPort } from "worker_threads";
|
|
||||||
// import { OLLAMA_HOST, OLLAMA_MODEL } from env
|
|
||||||
|
|
||||||
// parentPort?.on("message", async (msg) => {
|
|
||||||
// if (msg.type === "classify") {
|
|
||||||
// const result = await classifyWithOllama(msg.text);
|
|
||||||
// parentPort?.postMessage({
|
|
||||||
// type: "result",
|
|
||||||
// id: msg.id,
|
|
||||||
// isShade: result.isShade,
|
|
||||||
// confidence: result.confidence,
|
|
||||||
// });
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
|
|
||||||
export {};
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"target": "ES2022",
|
|
||||||
"module": "commonjs",
|
|
||||||
"lib": ["ES2022"],
|
|
||||||
"outDir": "./dist",
|
|
||||||
"rootDir": "./src",
|
|
||||||
"strict": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"forceConsistentCasingInFileNames": true,
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"declaration": true,
|
|
||||||
"declarationMap": true,
|
|
||||||
"sourceMap": true
|
|
||||||
},
|
|
||||||
"include": ["src/**/*"],
|
|
||||||
"exclude": ["node_modules", "dist", "data"]
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user