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:
prosolis
2026-03-08 18:25:18 -07:00
parent 07c8674fe0
commit ddb196aaad
91 changed files with 12865 additions and 13909 deletions

View File

@@ -1,57 +1,39 @@
# Matrix
MATRIX_HOMESERVER_URL=https://matrix.example.com
MATRIX_ACCESS_TOKEN=syt_your_token_here
MATRIX_BOT_USER_ID=@freebee:example.com
MATRIX_BOT_PASSWORD=
# ---- Matrix Connection ----
HOMESERVER_URL=https://matrix.example.com
BOT_USER_ID=@gogobee:example.com
BOT_PASSWORD=your_password_here
BOT_DISPLAY_NAME=GogoBee
# Which rooms the bot actively posts scheduled content to (comma-separated)
BOT_ROOMS=!roomid:example.com
# Which rooms the bot posts scheduled content to (comma-separated room IDs)
BROADCAST_ROOMS=!roomid:example.com
# Admins who can run admin-only commands
BOT_ADMIN_USERS=@yourmxid:example.com
# Admins who can run admin-only commands (comma-separated Matrix user IDs)
ADMIN_USERS=@yourmxid:example.com
# Runtime
BOT_PREFIX=!
# ---- Runtime ----
DATA_DIR=./data
LOG_LEVEL=info
# External APIs
# ---- External API Keys (all optional) ----
RAWG_API_KEY=
WORDNIK_API_KEY=
CALENDARIFIC_API_KEY=
ASIAN_HOLIDAY_COUNTRIES=JP,CN,KR,IN,TH,VN,TW,PH
HEBCAL_API_KEY=
HOLIDAY_COUNTRIES=US,GB,CA,AU,PT,IN,JP,DE,FR,BR,MX,IT,ES,KR,NL,SE,NO,IE,NZ,ZA,PH
OPENWEATHER_API_KEY=
FINNHUB_API_KEY=
BANDSINTOWN_API_KEY=
TMDB_API_KEY=
# ---- Self-Hosted Services (optional) ----
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_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)
RATELIMIT_WEATHER=5
# ---- Feature Flags ----
FEATURE_URL_PREVIEW=
FEATURE_SHADE=
FEATURE_TRIVIA=true # set to "false" to disable trivia
# ---- Rate Limits (per user per day) ----
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
View File

@@ -1,8 +1,5 @@
dist/
data/
.env
.env.*
!.env.example
node_modules/
merge_env.py
Twinbee Build Plan.md
gogobee

View File

@@ -1,12 +1,18 @@
FROM node:20-alpine
FROM golang:1.24-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
COPY . .
RUN CGO_ENABLED=0 go build -tags goolm -o gogobee .
COPY tsconfig.json ./
COPY src/ ./src/
RUN npx tsc
FROM alpine:3.21
RUN apk add --no-cache ca-certificates tzdata
WORKDIR /app
COPY --from=builder /app/gogobee .
CMD ["node", "dist/index.js"]
VOLUME /app/data
ENV DATA_DIR=/app/data
CMD ["./gogobee"]

1165
README.md

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,9 @@
services:
freebee:
gogobee:
build: .
restart: unless-stopped
env_file: .env
volumes:
# CRITICAL: The crypto/ subdirectory contains the bot's device identity.
# Losing it means the bot must re-verify in all encrypted rooms.
# Contains the SQLite database (bot state + crypto store) and device.json.
# Losing this means the bot must re-verify in all encrypted rooms.
- ./data:/app/data

43
go.mod Normal file
View 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
View 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
View 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
View 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
View 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
}

View 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
View 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
View 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
View 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
View 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")
}

View 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
View 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
View 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
View 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
View 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(&timesInsulted, &timesInsulting); 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
}

View 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
View 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
View 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
View 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&region=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
View 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
View 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())
}

View 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
}

View 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
}

View 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)
}

View 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
View 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
View 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
View 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
View 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(&quote); err != nil {
return nil, err
}
return &quote, 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
View 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
View 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
View 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
View 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
View 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(&quoteText, &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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

View File

@@ -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"
}
}

View File

@@ -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);
}

View File

@@ -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);
});

View File

@@ -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;
}
}

View File

@@ -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:0004: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"));
}
}

View File

@@ -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}`);
}
}
}
}
}

View File

@@ -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}`);
}
}
}
}

View File

@@ -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;
}
}

View File

@@ -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");
}
}

View File

@@ -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";
}
}
}

View File

@@ -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`;
}
});
}
}

View File

@@ -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.`
);
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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
);
}
}
}
}

View File

@@ -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 [];
}
}
}

View File

@@ -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;
}
}

View File

@@ -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")}`);
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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(" ");
}
}

View File

@@ -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}&region=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);
}
}

View File

@@ -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`;
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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}.`);
}
}
}

View File

@@ -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")}`);
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}
}
}

View File

@@ -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}`);
}
}

View File

@@ -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")}`);
}
}

View File

@@ -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")}`);
}
}

View File

@@ -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.");
}
}
}

View File

@@ -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(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#039;/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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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}".`);
}
}
}

View File

@@ -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;
}
}

View File

@@ -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!");
}
}

View File

@@ -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}`);
}
}
}

View File

@@ -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")}`);
}
}

View File

@@ -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;
}
}

View File

@@ -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;

View File

@@ -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";
}

View File

@@ -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 {};

View File

@@ -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"]
}