From 5549b3545e58ce871ee7bc6aa9f5e025ff08daa7 Mon Sep 17 00:00:00 2001 From: PTyTb Date: Wed, 15 Apr 2026 08:00:15 +0300 Subject: [PATCH] =?UTF-8?q?=D0=B7=D0=B0=D0=BB=D0=B8=D0=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 26 + .idea/.gitignore | 10 + .idea/go.imports.xml | 10 + .idea/inspectionProfiles/Project_Default.xml | 36 + .idea/modules.xml | 8 + .idea/stream-bot.iml | 9 + .idea/vcs.xml | 6 + app.ico | Bin 0 -> 93062 bytes go.mod | 32 + go.sum | 104 ++ internal/ai/chatgpt.go | 16 + internal/ai/factory.go | 22 + internal/ai/gigachat.go | 174 +++ internal/ai/ollama.go | 75 + internal/ai/provider.go | 7 + internal/audio/audio.go | 78 ++ internal/commands/processor.go | 226 +++ internal/db/db.go | 531 +++++++ internal/events/processor.go | 166 +++ internal/gui/window.go | 176 +++ internal/hotkey/hotkey_windows.go | 120 ++ internal/logger/logger.go | 165 +++ internal/notifications/manager.go | 66 + internal/parser/parser.go | 232 ++++ internal/platforms/manager.go | 213 +++ internal/platforms/twitch.go | 234 ++++ internal/platforms/twitch_auth.go | 134 ++ internal/platforms/twitch_eventsub.go | 416 ++++++ internal/twitchapi/twitchapi.go | 430 ++++++ internal/userstats/userstats.go | 62 + internal/webservices/alert.go | 208 +++ internal/webservices/base.go | 37 + internal/webservices/chat.go | 154 +++ internal/webservices/manager.go | 180 +++ internal/webservices/service.go | 10 + internal/webservices/types.go | 16 + internal/webui/server.go | 1309 ++++++++++++++++++ internal/webui/static/styles.css | 414 ++++++ internal/webui/templates/ai.html | 159 +++ internal/webui/templates/base.html | 45 + internal/webui/templates/commands.html | 434 ++++++ internal/webui/templates/dashboard.html | 142 ++ internal/webui/templates/events.html | 296 ++++ internal/webui/templates/hotkeys.html | 28 + internal/webui/templates/logs.html | 60 + internal/webui/templates/notifications.html | 174 +++ internal/webui/templates/platforms.html | 176 +++ internal/webui/templates/webservices.html | 296 ++++ main.go | 142 ++ rsrc.syso | Bin 0 -> 93936 bytes walk.manifest | 9 + 51 files changed, 8073 insertions(+) create mode 100644 .gitignore create mode 100644 .idea/.gitignore create mode 100644 .idea/go.imports.xml create mode 100644 .idea/inspectionProfiles/Project_Default.xml create mode 100644 .idea/modules.xml create mode 100644 .idea/stream-bot.iml create mode 100644 .idea/vcs.xml create mode 100644 app.ico create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/ai/chatgpt.go create mode 100644 internal/ai/factory.go create mode 100644 internal/ai/gigachat.go create mode 100644 internal/ai/ollama.go create mode 100644 internal/ai/provider.go create mode 100644 internal/audio/audio.go create mode 100644 internal/commands/processor.go create mode 100644 internal/db/db.go create mode 100644 internal/events/processor.go create mode 100644 internal/gui/window.go create mode 100644 internal/hotkey/hotkey_windows.go create mode 100644 internal/logger/logger.go create mode 100644 internal/notifications/manager.go create mode 100644 internal/parser/parser.go create mode 100644 internal/platforms/manager.go create mode 100644 internal/platforms/twitch.go create mode 100644 internal/platforms/twitch_auth.go create mode 100644 internal/platforms/twitch_eventsub.go create mode 100644 internal/twitchapi/twitchapi.go create mode 100644 internal/userstats/userstats.go create mode 100644 internal/webservices/alert.go create mode 100644 internal/webservices/base.go create mode 100644 internal/webservices/chat.go create mode 100644 internal/webservices/manager.go create mode 100644 internal/webservices/service.go create mode 100644 internal/webservices/types.go create mode 100644 internal/webui/server.go create mode 100644 internal/webui/static/styles.css create mode 100644 internal/webui/templates/ai.html create mode 100644 internal/webui/templates/base.html create mode 100644 internal/webui/templates/commands.html create mode 100644 internal/webui/templates/dashboard.html create mode 100644 internal/webui/templates/events.html create mode 100644 internal/webui/templates/hotkeys.html create mode 100644 internal/webui/templates/logs.html create mode 100644 internal/webui/templates/notifications.html create mode 100644 internal/webui/templates/platforms.html create mode 100644 internal/webui/templates/webservices.html create mode 100644 main.go create mode 100644 rsrc.syso create mode 100644 walk.manifest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3fef0f0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# ---> Go +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.mp3 +*.db +*.env +*.log + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work diff --git a/.idea/.gitignore b/.idea/.gitignore new file mode 100644 index 0000000..30cf57e --- /dev/null +++ b/.idea/.gitignore @@ -0,0 +1,10 @@ +# Default ignored files +/shelf/ +/workspace.xml +# Editor-based HTTP Client requests +/httpRequests/ +# Ignored default folder with query files +/queries/ +# Datasource local storage ignored files +/dataSources/ +/dataSources.local.xml diff --git a/.idea/go.imports.xml b/.idea/go.imports.xml new file mode 100644 index 0000000..644cdf0 --- /dev/null +++ b/.idea/go.imports.xml @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..56fe1b7 --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,36 @@ + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000..1b00b70 --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/stream-bot.iml b/.idea/stream-bot.iml new file mode 100644 index 0000000..5e764c4 --- /dev/null +++ b/.idea/stream-bot.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..35eb1dd --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app.ico b/app.ico new file mode 100644 index 0000000000000000000000000000000000000000..dca514ba007ec36a8948162f570e8baeebb2706b GIT binary patch literal 93062 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 O`F}QD{;U1ukNpd>Yhqvk literal 0 HcmV?d00001 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..547f23d --- /dev/null +++ b/go.mod @@ -0,0 +1,32 @@ +module stream-bot + +go 1.25.0 + +require ( + github.com/faiface/beep v1.1.0 + github.com/gempir/go-twitch-irc/v4 v4.4.1 + github.com/gorilla/websocket v1.5.3 + modernc.org/sqlite v1.48.1 +) + +require ( + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hajimehoshi/go-mp3 v0.3.0 // indirect + github.com/hajimehoshi/oto v0.7.1 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 // indirect + github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 // indirect + golang.org/x/image v0.37.0 // indirect + golang.org/x/mobile v0.0.0-20260217195705-b56b3793a9c4 // indirect + golang.org/x/sys v0.42.0 // indirect + gopkg.in/Knetic/govaluate.v3 v3.0.0 // indirect + modernc.org/libc v1.70.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..ad4cacb --- /dev/null +++ b/go.sum @@ -0,0 +1,104 @@ +github.com/DATA-DOG/go-sqlmock v1.3.3/go.mod h1:f/Ixk793poVmq4qj/V1dPUg2JEAKC73Q5eFN3EC/SaM= +github.com/d4l3k/messagediff v1.2.2-0.20190829033028-7e0a312ae40b/go.mod h1:Oozbb1TVXFac9FtSIxHBMnBCq2qeH/2KkEQxENCrlLo= +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/faiface/beep v1.1.0 h1:A2gWP6xf5Rh7RG/p9/VAW2jRSDEGQm5sbOb38sf5d4c= +github.com/faiface/beep v1.1.0/go.mod h1:6I8p6kK2q4opL/eWb+kAkk38ehnTunWeToJB+s51sT4= +github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg= +github.com/gdamore/tcell v1.3.0/go.mod h1:Hjvr+Ofd+gLglo7RYKxxnzCBmev3BzsS67MebKS4zMM= +github.com/gempir/go-twitch-irc/v4 v4.4.1 h1:R1WxeDyOiwHpt6rn96yZcXTS+Bri30n7pNvIjTMH598= +github.com/gempir/go-twitch-irc/v4 v4.4.1/go.mod h1:QsOMMAk470uxQ7EYD9GJBGAVqM/jDrXBNbuePfTauzg= +github.com/go-audio/audio v1.0.0/go.mod h1:6uAu0+H2lHkwdGsAY+j2wHPNPpPoeg5AaEFh9FlA+Zs= +github.com/go-audio/riff v1.0.0/go.mod h1:l3cQwc85y79NQFCRB7TiPoNiaijp6q8Z0Uv38rVG498= +github.com/go-audio/wav v1.0.0/go.mod h1:3yoReyQOsiARkvPl3ERCi8JFjihzG6WhjYpZCf5zAWE= +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/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hajimehoshi/go-mp3 v0.3.0 h1:fTM5DXjp/DL2G74HHAs/aBGiS9Tg7wnp+jkU38bHy4g= +github.com/hajimehoshi/go-mp3 v0.3.0/go.mod h1:qMJj/CSDxx6CGHiZeCgbiq2DSUkbK0UbtXShQcnfyMM= +github.com/hajimehoshi/oto v0.6.1/go.mod h1:0QXGEkbuJRohbJaxr7ZQSxnju7hEhseiPx2hrh6raOI= +github.com/hajimehoshi/oto v0.7.1 h1:I7maFPz5MBCwiutOrz++DLdbr4rTzBsbBuV2VpgU9kk= +github.com/hajimehoshi/oto v0.7.1/go.mod h1:wovJ8WWMfFKvP587mhHgot/MBr4DnNy9m6EepeVGnos= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/icza/bitio v1.0.0/go.mod h1:0jGnlLAx8MKMr9VGnn/4YrvZiprkvBelsVIbA9Jjr9A= +github.com/icza/mighty v0.0.0-20180919140131-cfd07d671de6/go.mod h1:xQig96I1VNBDIWGCdTt54nHt6EeI639SmHycLYL7FkA= +github.com/jfreymuth/oggvorbis v1.0.1/go.mod h1:NqS+K+UXKje0FUYUPosyQ+XTVvjmVjps1aEZH1sumIk= +github.com/jfreymuth/vorbis v1.0.0/go.mod h1:8zy3lUAm9K/rJJk223RKy6vjCZTWC61NA2QD06bfOE0= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/lucasb-eyer/go-colorful v1.0.2/go.mod h1:0MS4r+7BZKSJ5mw4/S5MPN+qHFF1fYclkSPilDOKW0s= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794 h1:NVRJ0Uy0SOFcXSKLsS65OmI1sgCCfiDUPj+cwnH7GZw= +github.com/lxn/walk v0.0.0-20210112085537-c389da54e794/go.mod h1:E23UucZGqpuUANJooIbHWCufXvOcT6E7Stq81gU+CSQ= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= +github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= +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-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mewkiz/flac v1.0.7/go.mod h1:yU74UH277dBUpqxPouHSQIar3G1X/QIclVbFahSd1pU= +github.com/mewkiz/pkg v0.0.0-20190919212034-518ade7978e2/go.mod h1:3E2FUC/qYUfM8+r9zAwpeHJzqRVVMIYnpzD/clwWxyA= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90 h1:kyPrwnEYXdME284bE7xgS9BPxhG7MCa5hw1/TpaTJVs= +golang.org/x/exp/shiny v0.0.0-20260312153236-7ab1446f8b90/go.mod h1:jqkJFnLVkS8zgKKY4+MOPCZtuZGw3hONUjhapUSwZ8c= +golang.org/x/image v0.0.0-20190220214146-31aff87c08e9/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= +golang.org/x/image v0.37.0 h1:ZiRjArKI8GwxZOoEtUfhrBtaCN+4b/7709dlT6SSnQA= +golang.org/x/image v0.37.0/go.mod h1:/3f6vaXC+6CEanU4KJxbcUZyEePbyKbaLoDOe4ehFYY= +golang.org/x/mobile v0.0.0-20190415191353-3e0bab5405d6/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= +golang.org/x/mobile v0.0.0-20260217195705-b56b3793a9c4 h1:uT3oYo9M38vJa7JpT4kCie2lJwOpoUrx7FvV0H7kXSc= +golang.org/x/mobile v0.0.0-20260217195705-b56b3793a9c4/go.mod h1:4OGHIUSBiIqyFAQDaX1tpY0BVnO20DvNDeATBu8aeFQ= +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-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +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-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190429190828-d89cdac9e872/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190626150813-e07cf5db2756/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= +golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= +gopkg.in/Knetic/govaluate.v3 v3.0.0 h1:18mUyIt4ZlRlFZAAfVetz4/rzlJs9yhN+U02F4u1AOc= +gopkg.in/Knetic/govaluate.v3 v3.0.0/go.mod h1:csKLBORsPbafmSCGTEh3U7Ozmsuq8ZSIlKk1bcqph0E= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw= +modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0= +modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM= +modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw= +modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo= +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.48.1 h1:S85iToyU6cgeojybE2XJlSbcsvcWkQ6qqNXJHtW5hWA= +modernc.org/sqlite v1.48.1/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig= +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= diff --git a/internal/ai/chatgpt.go b/internal/ai/chatgpt.go new file mode 100644 index 0000000..ac29811 --- /dev/null +++ b/internal/ai/chatgpt.go @@ -0,0 +1,16 @@ +package ai + +import ( + "context" + "fmt" +) + +type ChatGPTProvider struct{} + +func NewChatGPTProvider(apiKey, model, systemPrompt string) *ChatGPTProvider { + return &ChatGPTProvider{} +} + +func (p *ChatGPTProvider) Ask(ctx context.Context, prompt string) (string, error) { + return "", fmt.Errorf("ChatGPT not implemented yet") +} diff --git a/internal/ai/factory.go b/internal/ai/factory.go new file mode 100644 index 0000000..8d806cf --- /dev/null +++ b/internal/ai/factory.go @@ -0,0 +1,22 @@ +package ai + +import ( + "fmt" + "stream-bot/internal/db" +) + +func NewProvider(cfg *db.AIConfig) (Provider, error) { + switch cfg.Provider { + case "ollama": + return NewOllamaProvider(cfg.Endpoint, cfg.Model, cfg.SystemPrompt), nil + case "chatgpt": + return NewChatGPTProvider(cfg.APIKey, cfg.Model, cfg.SystemPrompt), nil + case "gigachat": + if cfg.ClientID == "" || cfg.ClientSecret == "" { + return nil, fmt.Errorf("client_id and client_secret required for GigaChat") + } + return NewGigaChatProvider(cfg.ClientID, cfg.ClientSecret, cfg.Endpoint, cfg.Model, cfg.SystemPrompt), nil + default: + return nil, fmt.Errorf("unknown provider: %s", cfg.Provider) + } +} diff --git a/internal/ai/gigachat.go b/internal/ai/gigachat.go new file mode 100644 index 0000000..5bdfe2a --- /dev/null +++ b/internal/ai/gigachat.go @@ -0,0 +1,174 @@ +package ai + +import ( + "bytes" + "context" + "crypto/tls" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +type gigaAuthResponse struct { + AccessToken string `json:"access_token"` + ExpiresIn int `json:"expires_in"` + TokenType string `json:"token_type"` +} + +type gigaMessage struct { + Role string `json:"role"` + Content string `json:"content"` +} + +type gigaChatRequest struct { + Model string `json:"model"` + Messages []gigaMessage `json:"messages"` + Stream bool `json:"stream"` +} + +type gigaChatResponse struct { + Choices []struct { + Message struct { + Content string `json:"content"` + } `json:"message"` + } `json:"choices"` +} + +type GigaChatProvider struct { + clientID string + authBasic string // уже готовый base64(clientID:secret) + endpoint string + model string + systemPrompt string + httpClient *http.Client + accessToken string + tokenExpiry time.Time +} + +func NewGigaChatProvider(clientID, authBasic, endpoint, model, systemPrompt string) *GigaChatProvider { + if endpoint == "" { + endpoint = "https://gigachat.devices.sberbank.ru/api/v1" + } + if model == "" { + model = "GigaChat" + } + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, + } + return &GigaChatProvider{ + clientID: strings.TrimSpace(clientID), + authBasic: strings.TrimSpace(authBasic), + endpoint: endpoint, + model: model, + systemPrompt: systemPrompt, + httpClient: &http.Client{Transport: tr, Timeout: 60 * time.Second}, + } +} + +func (p *GigaChatProvider) getToken(ctx context.Context) (string, error) { + if p.accessToken != "" && time.Now().Before(p.tokenExpiry) { + return p.accessToken, nil + } + + authURL := "https://ngw.devices.sberbank.ru:9443/api/v2/oauth" + bodyData := "scope=GIGACHAT_API_PERS" + + req, err := http.NewRequestWithContext(ctx, "POST", authURL, strings.NewReader(bodyData)) + if err != nil { + return "", err + } + + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", "Basic "+p.authBasic) + req.Header.Set("RqUID", p.clientID) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + resp, err := p.httpClient.Do(req) + if err != nil { + return "", err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("gigachat auth error: %d %s", resp.StatusCode, string(bodyBytes)) + } + + var authResp gigaAuthResponse + if err := json.Unmarshal(bodyBytes, &authResp); err != nil { + return "", err + } + + p.accessToken = authResp.AccessToken + p.tokenExpiry = time.Now().Add(time.Duration(authResp.ExpiresIn-60) * time.Second) + return p.accessToken, nil +} + +func (p *GigaChatProvider) Ask(ctx context.Context, prompt string) (string, error) { + token, err := p.getToken(ctx) + if err != nil { + return "", err + } + + messages := []gigaMessage{ + {Role: "system", Content: p.systemPrompt}, + {Role: "user", Content: prompt}, + } + + reqBody := gigaChatRequest{ + Model: p.model, + Messages: messages, + Stream: false, + } + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + + chatURL := p.endpoint + "/chat/completions" + req, err := http.NewRequestWithContext(ctx, "POST", chatURL, bytes.NewReader(jsonData)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + req.Header.Set("RqUID", p.clientID) + req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36") + + resp, err := p.httpClient.Do(req) + if err != nil { + return "", err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("gigachat api error: %d %s", resp.StatusCode, string(bodyBytes)) + } + + var chatResp gigaChatResponse + if err := json.Unmarshal(bodyBytes, &chatResp); err != nil { + return "", err + } + if len(chatResp.Choices) == 0 { + return "", fmt.Errorf("no response from gigachat") + } + return chatResp.Choices[0].Message.Content, nil +} diff --git a/internal/ai/ollama.go b/internal/ai/ollama.go new file mode 100644 index 0000000..d155a89 --- /dev/null +++ b/internal/ai/ollama.go @@ -0,0 +1,75 @@ +package ai + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +type OllamaProvider struct { + endpoint string + model string + systemPrompt string + client *http.Client +} + +type ollamaRequest struct { + Model string `json:"model"` + Prompt string `json:"prompt"` + System string `json:"system,omitempty"` + Stream bool `json:"stream"` +} + +type ollamaResponse struct { + Response string `json:"response"` +} + +func NewOllamaProvider(endpoint, model, systemPrompt string) *OllamaProvider { + if endpoint == "" { + endpoint = "http://localhost:11434" + } + return &OllamaProvider{ + endpoint: endpoint, + model: model, + systemPrompt: systemPrompt, + client: &http.Client{Timeout: 30 * time.Second}, + } +} + +func (p *OllamaProvider) Ask(ctx context.Context, prompt string) (string, error) { + reqBody := ollamaRequest{ + Model: p.model, + Prompt: prompt, + System: p.systemPrompt, + Stream: false, + } + jsonData, err := json.Marshal(reqBody) + if err != nil { + return "", err + } + url := p.endpoint + "/api/generate" + req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(jsonData)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := p.client.Do(req) + if err != nil { + return "", err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("ollama error: %d", resp.StatusCode) + } + var ollamaResp ollamaResponse + if err := json.NewDecoder(resp.Body).Decode(&ollamaResp); err != nil { + return "", err + } + return ollamaResp.Response, nil +} diff --git a/internal/ai/provider.go b/internal/ai/provider.go new file mode 100644 index 0000000..c40f544 --- /dev/null +++ b/internal/ai/provider.go @@ -0,0 +1,7 @@ +package ai + +import "context" + +type Provider interface { + Ask(ctx context.Context, prompt string) (string, error) +} diff --git a/internal/audio/audio.go b/internal/audio/audio.go new file mode 100644 index 0000000..3dd4422 --- /dev/null +++ b/internal/audio/audio.go @@ -0,0 +1,78 @@ +package audio + +import ( + "math" + "os" + "stream-bot/internal/logger" + + "github.com/faiface/beep" + "github.com/faiface/beep/effects" + "github.com/faiface/beep/mp3" + "github.com/faiface/beep/speaker" + "github.com/faiface/beep/wav" +) + +var initialized bool + +func Init() error { + // Увеличенный буфер (было 200, стало 4096) для плавности + err := speaker.Init(44100, 4096) + if err == nil { + initialized = true + } + return err +} + +func Close() { + if initialized { + speaker.Close() + } +} + +func PlaySound(filePath string) error { + return PlayWithVolume(filePath, 100) +} + +func PlayWithVolume(filePath string, volume int) error { + if !initialized { + logger.Warn("Audio player not initialized, cannot play %s", filePath) + return nil + } + if volume <= 0 { + return nil + } + if volume > 100 { + volume = 100 + } + + f, err := os.Open(filePath) + if err != nil { + return err + } + + var streamer beep.StreamSeekCloser + var errDecode error + if len(filePath) > 4 && filePath[len(filePath)-4:] == ".mp3" { + streamer, _, errDecode = mp3.Decode(f) + } else { + streamer, _, errDecode = wav.Decode(f) + } + if errDecode != nil { + _ = f.Close() + return errDecode + } + + gain := 20 * math.Log10(float64(volume)/100.0) + volumeStreamer := &effects.Volume{ + Streamer: streamer, + Base: 2, + Volume: gain, + Silent: false, + } + + speaker.Play(beep.Seq(volumeStreamer, beep.Callback(func() { + _ = streamer.Close() + _ = f.Close() + }))) + return nil +} diff --git a/internal/commands/processor.go b/internal/commands/processor.go new file mode 100644 index 0000000..2107d4c --- /dev/null +++ b/internal/commands/processor.go @@ -0,0 +1,226 @@ +package commands + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "math/rand" + "net/http" + "path/filepath" + "stream-bot/internal/ai" + "stream-bot/internal/audio" + "stream-bot/internal/db" + "stream-bot/internal/logger" + "stream-bot/internal/parser" + "stream-bot/internal/twitchapi" + "stream-bot/internal/userstats" + "stream-bot/internal/webservices" + "strings" + "sync" + "time" +) + +type Processor struct { + cooldowns sync.Map + twitchAPI *twitchapi.TwitchAPI + aiProvider ai.Provider + webSrvMgr *webservices.Manager + userStats *userstats.Store +} + +func NewProcessor(twitchAPI *twitchapi.TwitchAPI, aiProvider ai.Provider, webSrvMgr *webservices.Manager, userStats *userstats.Store) *Processor { + return &Processor{ + twitchAPI: twitchAPI, + aiProvider: aiProvider, + webSrvMgr: webSrvMgr, + userStats: userStats, + } +} + +func (p *Processor) ProcessCommand(trigger, username, platform string, isMod, isBroadcaster bool, args string) (response string, soundFiles []string, err error) { + cmds, err := db.GetCommands() + if err != nil { + return "", nil, err + } + var cmd *db.Command + for _, c := range cmds { + if c.Trigger == trigger && c.Enabled { + cmd = &c + break + } + } + if cmd == nil { + return "", nil, nil + } + + // Проверка прав + switch cmd.Permission { + case "broadcaster": + if !isBroadcaster { + return "", nil, nil + } + case "moderator": + if !isMod && !isBroadcaster { + return "", nil, nil + } + } + + // Кулдаун + if cmd.CooldownSec > 0 { + last, ok := p.cooldowns.Load(cmd.Trigger) + if ok && time.Since(last.(time.Time)) < time.Duration(cmd.CooldownSec)*time.Second { + return "", nil, nil + } + p.cooldowns.Store(cmd.Trigger, time.Now()) + } + + // Обработка тегов и + template := cmd.Template + aiResult := "" + if strings.Contains(template, "") && p.aiProvider != nil { + if args == "" { + aiResult = "Вы не задали вопрос." + } else { + aiResult, err = p.aiProvider.Ask(context.Background(), args) + if err != nil { + logger.Error("AI error: %v", err) + aiResult = "Ошибка при обращении к нейросети." + } + } + } + if platform == "twitch" && (strings.Contains(template, "") || strings.Contains(template, "")) { + broadcasterID, err := p.twitchAPI.GetBroadcasterID() + if err == nil { + userID, err := p.twitchAPI.GetUserID(username) + if err == nil { + if strings.Contains(template, "") { + createdAt, err := p.twitchAPI.GetUserCreatedAt(userID) + if err != nil { + template = strings.ReplaceAll(template, "", "неизвестно") + } else { + ageStr := twitchapi.FormatDuration(createdAt) + template = strings.ReplaceAll(template, "", ageStr) + } + } + if strings.Contains(template, "") { + followedAt, err := p.twitchAPI.GetFollowCreatedAt(broadcasterID, userID) + if err != nil { + template = strings.ReplaceAll(template, "", "не подписан") + } else { + followStr := twitchapi.FormatDuration(followedAt) + template = strings.ReplaceAll(template, "", followStr) + } + } + } + } + } + + // Парсинг шаблона (теперь возвращает ещё и timeoutMinutes) + response, soundFiles, timeoutMinutes, err := parser.ParseTemplate(template, username, args, aiResult, p.getRandomUsername) + if err != nil { + logger.Error("Parse error for command %s: %v", cmd.Trigger, err) + return "", nil, err + } + + // Обработка таймаута (если есть тег ) + if timeoutMinutes > 0 && platform == "twitch" { + if err := p.timeoutUser(username, timeoutMinutes); err != nil { + logger.Error("Failed to timeout user %s: %v", username, err) + } else { + logger.Info("User %s timed out for %d minutes", username, timeoutMinutes) + } + } + + // Воспроизведение звуков + if len(soundFiles) > 0 && p.webSrvMgr != nil { + for _, sf := range soundFiles { + p.sendSoundToAlertServices(sf) + } + } else if len(soundFiles) > 0 { + for _, sf := range soundFiles { + if err := audio.PlaySound(sf); err != nil { + logger.Error("Failed to play sound %s: %v", sf, err) + } + } + } + return response, soundFiles, nil +} + +// timeoutUser отправляет пользователя в таймаут через Twitch API +func (p *Processor) timeoutUser(username string, minutes int) error { + broadcasterID, err := p.twitchAPI.GetBroadcasterID() + if err != nil { + return err + } + userID, err := p.twitchAPI.GetUserID(username) + if err != nil { + return err + } + moderatorID := broadcasterID // используем ID стримера как модератора + seconds := minutes * 60 + return p.twitchAPI.TimeoutUser(broadcasterID, moderatorID, userID, seconds) +} + +func (p *Processor) UpdateAIProvider(provider ai.Provider) { + p.aiProvider = provider +} + +func (p *Processor) sendSoundToAlertServices(soundFile string) { + if p.webSrvMgr == nil { + return + } + duplicate, _ := db.GetSetting("duplicate_command_sounds") + duplicateEnabled := duplicate == "true" + + services := p.webSrvMgr.GetAllServices() + for _, srv := range services { + if srv.GetType() != "alert" { + continue + } + port := srv.GetPort() + url := fmt.Sprintf("http://localhost:%d/notify", port) + filename := filepath.Base(soundFile) + payload := map[string]interface{}{ + "sound": "/sounds/" + filename, + "duration": 1, + "title": "", + "text": "", + } + body, _ := json.Marshal(payload) + go func() { + resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + if err != nil { + logger.Error("Failed to send sound to alert service %d: %v", port, err) + } else { + _ = resp.Body.Close() + } + }() + } + if duplicateEnabled { + fullPath := filepath.Join("data", "sounds", filepath.Base(soundFile)) + if err := audio.PlaySound(fullPath); err != nil { + logger.Error("Failed to play duplicated sound %s: %v", fullPath, err) + } + } +} + +// getRandomUsername возвращает случайное имя из активных пользователей чата +func (p *Processor) getRandomUsername() string { + users := p.userStats.GetAll() + if len(users) == 0 { + return "кого-то" + } + // Отфильтруем пустые имена + var names []string + for _, u := range users { + if u.Username != "" { + names = append(names, u.Username) + } + } + if len(names) == 0 { + return "кого-то" + } + rand.Seed(time.Now().UnixNano()) + return names[rand.Intn(len(names))] +} diff --git a/internal/db/db.go b/internal/db/db.go new file mode 100644 index 0000000..ad060ed --- /dev/null +++ b/internal/db/db.go @@ -0,0 +1,531 @@ +package db + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "sync" + "time" + + _ "modernc.org/sqlite" +) + +var ( + db *sql.DB + once sync.Once +) + +type MarkedUser struct { + Username string + Platform string + LastMarked string // YYYY-MM-DD +} + +func Init(path string) error { + var err error + once.Do(func() { + db, err = sql.Open("sqlite", path) + if err != nil { + return + } + err = createTables() + }) + return err +} + +func Close() { + if db != nil { + _ = db.Close() + } +} + +func createTables() error { + schema := ` + CREATE TABLE IF NOT EXISTS commands ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + trigger TEXT UNIQUE NOT NULL, + template TEXT NOT NULL, + enabled BOOLEAN DEFAULT 1, + cooldown_sec INTEGER DEFAULT 0, + permission TEXT DEFAULT 'everyone' + ); + CREATE TABLE IF NOT EXISTS events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + platform TEXT NOT NULL, + event_name TEXT NOT NULL, + action_chain TEXT NOT NULL, + UNIQUE(platform, event_name) + ); + CREATE TABLE IF NOT EXISTS hotkey_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + min_donation_amount INTEGER NOT NULL, + combination TEXT NOT NULL, + platform TEXT NOT NULL + ); + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT + ); + CREATE TABLE IF NOT EXISTS platform_tokens ( + platform TEXT PRIMARY KEY, + client_id TEXT, + client_secret TEXT, + user_token TEXT, + user_refresh TEXT, + bot_token TEXT, + bot_refresh TEXT, + user_login TEXT, + bot_login TEXT + ); + CREATE TABLE IF NOT EXISTS marked_users ( + username TEXT NOT NULL, + platform TEXT NOT NULL, + last_marked TEXT, + PRIMARY KEY (username, platform) + ); + CREATE TABLE IF NOT EXISTS ai_config ( + id INTEGER PRIMARY KEY CHECK (id = 1), + provider TEXT NOT NULL, + api_key TEXT, + endpoint TEXT, + model TEXT, + system_prompt TEXT, + client_id TEXT, + client_secret TEXT + ); + CREATE TABLE IF NOT EXISTS notification_settings ( + event_name TEXT PRIMARY KEY, + sound_file TEXT NOT NULL, + volume INTEGER DEFAULT 70, + enabled BOOLEAN DEFAULT 1 + ); + CREATE TABLE IF NOT EXISTS web_services ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + service_type TEXT NOT NULL, + port INTEGER NOT NULL, + config_json TEXT NOT NULL, + enabled BOOLEAN DEFAULT 1, + running BOOLEAN DEFAULT 0, + started_at DATETIME + ); + ` + _, err := db.Exec(schema) + return err +} + +// ---------- Команды ---------- +type Command struct { + ID int + Trigger string + Template string + Enabled bool + CooldownSec int + Permission string +} + +func GetCommands() ([]Command, error) { + rows, err := db.Query("SELECT id, trigger, template, enabled, cooldown_sec, permission FROM commands") + if err != nil { + return nil, err + } + defer func(rows *sql.Rows) { + _ = rows.Close() + }(rows) + var cmds []Command + for rows.Next() { + var c Command + err := rows.Scan(&c.ID, &c.Trigger, &c.Template, &c.Enabled, &c.CooldownSec, &c.Permission) + if err != nil { + return nil, err + } + cmds = append(cmds, c) + } + return cmds, nil +} + +func AddCommand(trigger, template string, enabled bool, cooldown int, perm string) error { + _, err := db.Exec("INSERT INTO commands (trigger, template, enabled, cooldown_sec, permission) VALUES (?, ?, ?, ?, ?)", + trigger, template, enabled, cooldown, perm) + return err +} + +func UpdateCommand(id int, trigger, template string, enabled bool, cooldown int, perm string) error { + _, err := db.Exec("UPDATE commands SET trigger=?, template=?, enabled=?, cooldown_sec=?, permission=? WHERE id=?", + trigger, template, enabled, cooldown, perm, id) + return err +} + +func DeleteCommand(id int) error { + _, err := db.Exec("DELETE FROM commands WHERE id=?", id) + return err +} + +// ---------- События (расширенные действия) ---------- +type Action struct { + Type string `json:"type"` // send_message, play_sound, press_hotkey, http_request, run_program, send_alert + // Общие поля + Text string `json:"text,omitempty"` // для send_message и send_alert + SoundFile string `json:"sound_file,omitempty"` // для play_sound и send_alert + Keys string `json:"keys,omitempty"` // для press_hotkey + URL string `json:"url,omitempty"` // для http_request + // Для run_program + Executable string `json:"executable,omitempty"` + Args string `json:"args,omitempty"` + // Для send_alert + Title string `json:"title,omitempty"` + AlertText string `json:"alert_text,omitempty"` + Image string `json:"image,omitempty"` + Duration int `json:"duration,omitempty"` // секунды + TargetWebServiceID int `json:"target_web_service_id,omitempty"` // 0 = все alert-сервисы +} + +// GetEventActions возвращает цепочку действий для события +func GetEventActions(platform, eventName string) ([]Action, error) { + var chainJSON string + err := db.QueryRow("SELECT action_chain FROM events WHERE platform=? AND event_name=?", platform, eventName).Scan(&chainJSON) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + var actions []Action + if err := json.Unmarshal([]byte(chainJSON), &actions); err != nil { + return nil, err + } + return actions, nil +} + +// SaveEventActions сохраняет цепочку действий для события (вставка или обновление) +func SaveEventActions(platform, eventName string, actions []Action) error { + chainJSON, err := json.Marshal(actions) + if err != nil { + return err + } + _, err = db.Exec(` + INSERT OR REPLACE INTO events (platform, event_name, action_chain) + VALUES (?, ?, ?) + `, platform, eventName, string(chainJSON)) + return err +} + +// ---------- Горячие клавиши по донатам ---------- +func GetHotkeyRules(platform string) (map[int]string, error) { + rows, err := db.Query("SELECT min_donation_amount, combination FROM hotkey_rules WHERE platform=?", platform) + if err != nil { + return nil, err + } + defer func(rows *sql.Rows) { + _ = rows.Close() + }(rows) + rules := make(map[int]string) + for rows.Next() { + var amount int + var comb string + if err := rows.Scan(&amount, &comb); err == nil { + rules[amount] = comb + } + } + return rules, nil +} + +func AddHotkeyRule(platform string, minAmount int, combination string) error { + _, err := db.Exec("INSERT INTO hotkey_rules (platform, min_donation_amount, combination) VALUES (?, ?, ?)", + platform, minAmount, combination) + return err +} + +func DeleteHotkeyRule(platform string, minAmount int) error { + _, err := db.Exec("DELETE FROM hotkey_rules WHERE platform=? AND min_donation_amount=?", platform, minAmount) + return err +} + +// ---------- Токены платформ ---------- +type PlatformTokens struct { + ClientID string + ClientSecret string + UserToken string + UserRefresh string + BotToken string + BotRefresh string + UserLogin string + BotLogin string +} + +func GetPlatformTokens(platform string) (*PlatformTokens, error) { + var pt PlatformTokens + row := db.QueryRow("SELECT client_id, client_secret, user_token, user_refresh, bot_token, bot_refresh, user_login, bot_login FROM platform_tokens WHERE platform=?", platform) + err := row.Scan(&pt.ClientID, &pt.ClientSecret, &pt.UserToken, &pt.UserRefresh, &pt.BotToken, &pt.BotRefresh, &pt.UserLogin, &pt.BotLogin) + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, err + } + return &pt, nil +} + +func SetPlatformTokens(platform string, pt *PlatformTokens) error { + _, err := db.Exec(` + INSERT OR REPLACE INTO platform_tokens + (platform, client_id, client_secret, user_token, user_refresh, bot_token, bot_refresh, user_login, bot_login) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + platform, pt.ClientID, pt.ClientSecret, pt.UserToken, pt.UserRefresh, pt.BotToken, pt.BotRefresh, pt.UserLogin, pt.BotLogin) + return err +} + +// ---------- Отметки пользователей ---------- +func IsUserMarked(username, platform string) (bool, string, error) { + var lastMarked string + err := db.QueryRow("SELECT last_marked FROM marked_users WHERE username=? AND platform=?", username, platform).Scan(&lastMarked) + if err == sql.ErrNoRows { + return false, "", nil + } + if err != nil { + return false, "", err + } + return true, lastMarked, nil +} + +func SetUserMarked(username, platform string, marked bool) error { + if marked { + _, err := db.Exec("INSERT OR REPLACE INTO marked_users (username, platform, last_marked) VALUES (?, ?, '')", username, platform) + return err + } else { + _, err := db.Exec("DELETE FROM marked_users WHERE username=? AND platform=?", username, platform) + return err + } +} + +func UpdateMarkedUserDate(username, platform string, t time.Time) error { + date := t.Format("2006-01-02") + _, err := db.Exec("UPDATE marked_users SET last_marked=? WHERE username=? AND platform=?", date, username, platform) + return err +} + +// ---------- AI конфиг ---------- +type AIConfig struct { + Provider string `json:"provider"` + APIKey string `json:"api_key"` + Endpoint string `json:"endpoint"` + Model string `json:"model"` + SystemPrompt string `json:"system_prompt"` + ClientID string `json:"client_id"` + ClientSecret string `json:"client_secret"` +} + +func SaveAIConfig(cfg *AIConfig) error { + _, err := db.Exec(` + INSERT OR REPLACE INTO ai_config (id, provider, api_key, endpoint, model, system_prompt, client_id, client_secret) + VALUES (1, ?, ?, ?, ?, ?, ?, ?)`, + cfg.Provider, cfg.APIKey, cfg.Endpoint, cfg.Model, cfg.SystemPrompt, cfg.ClientID, cfg.ClientSecret) + return err +} + +func GetAIConfig() (*AIConfig, error) { + var cfg AIConfig + row := db.QueryRow(` + SELECT provider, api_key, endpoint, model, system_prompt, client_id, client_secret + FROM ai_config WHERE id = 1`) + err := row.Scan(&cfg.Provider, &cfg.APIKey, &cfg.Endpoint, &cfg.Model, &cfg.SystemPrompt, &cfg.ClientID, &cfg.ClientSecret) + if errors.Is(err, sql.ErrNoRows) { + // По умолчанию + return &AIConfig{ + Provider: "ollama", + SystemPrompt: "ты в чате твитча, ответь одним предложением.", + }, nil + } + if err != nil { + return nil, err + } + return &cfg, nil +} + +// ---------- Уведомления ---------- +type NotificationSetting struct { + EventName string `json:"event_name"` + SoundFile string `json:"sound_file"` + Volume int `json:"volume"` + Enabled bool `json:"enabled"` +} + +func GetAllNotificationSettings() ([]NotificationSetting, error) { + rows, err := db.Query("SELECT event_name, sound_file, volume, enabled FROM notification_settings") + if err != nil { + return nil, err + } + defer func(rows *sql.Rows) { + _ = rows.Close() + }(rows) + var settings []NotificationSetting + for rows.Next() { + var ns NotificationSetting + if err := rows.Scan(&ns.EventName, &ns.SoundFile, &ns.Volume, &ns.Enabled); err != nil { + return nil, err + } + settings = append(settings, ns) + } + return settings, nil +} + +func SaveNotificationSetting(ns *NotificationSetting) error { + _, err := db.Exec(` + INSERT OR REPLACE INTO notification_settings (event_name, sound_file, volume, enabled) + VALUES (?, ?, ?, ?)`, + ns.EventName, ns.SoundFile, ns.Volume, ns.Enabled) + return err +} + +// ---------- Веб-сервисы ---------- +type ChatWebConfig struct { + BackgroundColor string `json:"bg_color"` + TextColor string `json:"text_color"` + FontSize int `json:"font_size"` + FontFamily string `json:"font_family"` + Opacity int `json:"opacity"` + MessageTimeoutSec int `json:"message_timeout_sec"` + MaxMessages int `json:"max_messages"` + ShowBadges bool `json:"show_badges"` + ShowTimestamps bool `json:"show_timestamps"` +} + +type AlertEventConfig struct { + Enabled bool `json:"enabled"` + TitleTemplate string `json:"title_template"` + TextTemplate string `json:"text_template"` + ImageFile string `json:"image_file"` + SoundFile string `json:"sound_file"` + DurationSec int `json:"duration_sec"` +} + +type AlertWebConfig struct { + Events map[string]AlertEventConfig `json:"events"` + DefaultDuration int `json:"default_duration"` + DefaultImage string `json:"default_image"` + DefaultSound string `json:"default_sound"` +} + +type WebService struct { + ID int `json:"id"` + Type string `json:"service_type"` + Port int `json:"port"` + ConfigJSON string `json:"config_json"` + Enabled bool `json:"enabled"` + Running bool `json:"running"` + StartedAt *time.Time `json:"started_at"` +} + +func (ws *WebService) GetChatConfig() (*ChatWebConfig, error) { + if ws.Type != "chat" { + return nil, fmt.Errorf("not a chat service") + } + var cfg ChatWebConfig + if err := json.Unmarshal([]byte(ws.ConfigJSON), &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func (ws *WebService) GetAlertConfig() (*AlertWebConfig, error) { + if ws.Type != "alert" { + return nil, fmt.Errorf("not an alert service") + } + var cfg AlertWebConfig + if err := json.Unmarshal([]byte(ws.ConfigJSON), &cfg); err != nil { + return nil, err + } + return &cfg, nil +} + +func GetAllWebServices() ([]WebService, error) { + rows, err := db.Query(`SELECT id, service_type, port, config_json, enabled, running, started_at FROM web_services`) + if err != nil { + return nil, err + } + defer func(rows *sql.Rows) { + _ = rows.Close() + }(rows) + var list []WebService + for rows.Next() { + var ws WebService + var startedAt sql.NullTime + err := rows.Scan(&ws.ID, &ws.Type, &ws.Port, &ws.ConfigJSON, &ws.Enabled, &ws.Running, &startedAt) + if err != nil { + return nil, err + } + if startedAt.Valid { + ws.StartedAt = &startedAt.Time + } + list = append(list, ws) + } + return list, nil +} + +func GetWebService(id int) (*WebService, error) { + var ws WebService + var startedAt sql.NullTime + err := db.QueryRow(`SELECT id, service_type, port, config_json, enabled, running, started_at FROM web_services WHERE id = ?`, id). + Scan(&ws.ID, &ws.Type, &ws.Port, &ws.ConfigJSON, &ws.Enabled, &ws.Running, &startedAt) + if err != nil { + return nil, err + } + if startedAt.Valid { + ws.StartedAt = &startedAt.Time + } + return &ws, nil +} + +func CreateWebService(serviceType string, port int, config interface{}) (int, error) { + configJSON, err := json.Marshal(config) + if err != nil { + return 0, err + } + res, err := db.Exec(`INSERT INTO web_services (service_type, port, config_json, enabled, running) VALUES (?, ?, ?, 1, 0)`, + serviceType, port, string(configJSON)) + if err != nil { + return 0, err + } + id, _ := res.LastInsertId() + return int(id), nil +} + +func UpdateWebService(id int, port int, config interface{}, enabled bool) error { + configJSON, err := json.Marshal(config) + if err != nil { + return err + } + _, err = db.Exec(`UPDATE web_services SET port = ?, config_json = ?, enabled = ? WHERE id = ?`, + port, string(configJSON), enabled, id) + return err +} + +func DeleteWebService(id int) error { + _, err := db.Exec(`DELETE FROM web_services WHERE id = ?`, id) + return err +} + +func SetWebServiceRunning(id int, running bool) error { + _, err := db.Exec(`UPDATE web_services SET running = ?, started_at = CURRENT_TIMESTAMP WHERE id = ?`, running, id) + return err +} + +// ---------- Настройки (ключ-значение) ---------- +// GetSetting возвращает значение настройки (пустая строка, если нет) +func GetSetting(key string) (string, error) { + var value string + err := db.QueryRow("SELECT value FROM settings WHERE key = ?", key).Scan(&value) + if errors.Is(err, sql.ErrNoRows) { + return "", nil + } + if err != nil { + return "", err + } + return value, nil +} + +// SetSetting сохраняет или обновляет настройку +func SetSetting(key, value string) error { + _, err := db.Exec("INSERT OR REPLACE INTO settings (key, value) VALUES (?, ?)", key, value) + return err +} diff --git a/internal/events/processor.go b/internal/events/processor.go new file mode 100644 index 0000000..76adf76 --- /dev/null +++ b/internal/events/processor.go @@ -0,0 +1,166 @@ +package events + +import ( + "stream-bot/internal/audio" + "stream-bot/internal/db" + "stream-bot/internal/hotkey" + "stream-bot/internal/logger" + "stream-bot/internal/webservices" + "strings" +) + +type Processor struct { + sendMessageFunc func(platform, text string) error + webSrvMgr *webservices.Manager +} + +// NewProcessor создаёт обработчик событий с функцией отправки сообщений и менеджером веб-сервисов +func NewProcessor(sendMessageFunc func(platform, text string) error, webSrvMgr *webservices.Manager) *Processor { + return &Processor{ + sendMessageFunc: sendMessageFunc, + webSrvMgr: webSrvMgr, + } +} + +func (p *Processor) ProcessEvent(platform, eventName string, params map[string]string) { + // 1. Выполняем действия, сохранённые в БД (send_message, play_sound, press_hotkey) + actions, err := db.GetEventActions(platform, eventName) + if err != nil { + logger.Error("Failed to get event actions: %v", err) + } else { + for _, action := range actions { + switch action.Type { + case "send_message": + if p.sendMessageFunc == nil { + logger.Error("sendMessageFunc is nil, cannot send message") + continue + } + text := action.Text + for k, v := range params { + placeholder := "{{" + k + "}}" + text = strings.ReplaceAll(text, placeholder, v) + } + if err := p.sendMessageFunc(platform, text); err != nil { + logger.Error("Send message error: %v", err) + } + case "play_sound": + if err := audio.PlaySound(action.SoundFile); err != nil { + logger.Error("Play sound error: %v", err) + } + case "press_hotkey": + if err := hotkey.PressCombination(action.Keys); err != nil { + logger.Error("Hotkey error: %v", err) + } + case "http_request": + logger.Info("[Event] HTTP request to %s", action.URL) + } + } + } + + //// 2. Отправляем уведомление во все запущенные alert-сервисы (если есть) + //if p.webSrvMgr == nil { + // return + //} + // + //// Формируем заголовок и текст уведомления на основе типа события + //title, text := p.formatEventNotification(eventName, params) + //if title == "" && text == "" { + // // Если событие не требует уведомления, не отправляем + // return + //} + // + //// Получаем звук для события (можно настроить позже, пока используем стандартные) + //soundFile := p.getSoundForEvent(eventName) + // + //payload := map[string]interface{}{ + // "title": title, + // "text": text, + // "duration": 5, + // "image": "", + // "sound": soundFile, + //} + // + //// Отправляем во все alert-сервисы + //services := p.webSrvMgr.GetAllServices() + //for _, srv := range services { + // if srv.GetType() != "alert" { + // continue + // } + // port := srv.GetPort() + // url := fmt.Sprintf("http://localhost:%d/notify", port) + // body, _ := json.Marshal(payload) + // go func(url string, body []byte) { + // resp, err := http.Post(url, "application/json", bytes.NewReader(body)) + // if err != nil { + // logger.Error("Failed to send alert to service %s: %v", url, err) + // } else { + // _ = resp.Body.Close() + // } + // }(url, body) + //} +} + +// formatEventNotification формирует заголовок и текст уведомления для события +func (p *Processor) formatEventNotification(eventName string, params map[string]string) (title, text string) { + switch eventName { + case "follow": + username := params["username"] + title = "Новый фолловер!" + text = username + " теперь с нами" + case "subscribe": + username := params["username"] + tier := params["tier"] + tierName := "" + switch tier { + case "1000": + tierName = "1 уровень" + case "2000": + tierName = "2 уровень" + case "3000": + tierName = "3 уровень" + default: + tierName = tier + } + title = "Спасибо за подписку!" + text = username + " подписался на " + tierName + case "gift_sub": + gifter := params["gifter"] + recipient := params["recipient"] + total := params["cumulative_total"] + title = "Подарочная подписка!" + if recipient != "" { + text = gifter + " подарил подписку для " + recipient + } else { + text = gifter + " подарил " + total + " подписок" + } + case "raid": + from := params["from"] + viewers := params["viewers"] + title = "Рейд!" + text = from + " привёл " + viewers + " зрителей" + case "reward_redemption": + username := params["username"] + reward := params["reward_title"] + title = "Активирована награда!" + text = username + " активировал " + reward + default: + return "", "" + } + return title, text +} + +// getSoundForEvent возвращает путь к звуковому файлу для события (можно вынести в настройки) +func (p *Processor) getSoundForEvent(eventName string) string { + // Здесь можно читать настройки из БД, пока используем заглушку + sounds := map[string]string{ + "follow": "/sounds/follow.mp3", + "subscribe": "/sounds/sub.mp3", + "gift_sub": "/sounds/gift.mp3", + "raid": "/sounds/raid.mp3", + "reward_redemption": "/sounds/reward.mp3", + } + if s, ok := sounds[eventName]; ok { + return s + } + return "/sounds/default.mp3" +} diff --git a/internal/gui/window.go b/internal/gui/window.go new file mode 100644 index 0000000..0bdc3d9 --- /dev/null +++ b/internal/gui/window.go @@ -0,0 +1,176 @@ +package gui + +import ( + "os/exec" + "runtime" + "stream-bot/internal/logger" + "time" + + "github.com/lxn/walk" + . "github.com/lxn/walk/declarative" +) + +const appVersion = "11.0.43" + +func Run(webURL string, + getChatStatus func() bool, + getEventSubStatus func() (connected bool, subscriptions []string)) { + + if runtime.GOOS != "windows" { + logger.Warn("GUI only supported on Windows, running without window") + return + } + + var urlLE *walk.LineEdit + var openBtn *walk.PushButton + var exitBtn *walk.PushButton + var chatStatusLabel, eventSubLabel *walk.Label + + var mw *walk.MainWindow + exitWithoutMinimize := false // флаг для полного закрытия + + err := MainWindow{ + AssignTo: &mw, + Title: "TTW_Bot v" + appVersion, + MinSize: Size{Width: 450, Height: 280}, + Size: Size{Width: 500, Height: 300}, + Layout: VBox{}, + Children: []Widget{ + Label{Text: "Веб-интерфейс бота доступен по адресу:"}, + LineEdit{ + AssignTo: &urlLE, + Text: webURL, + ReadOnly: true, + }, + PushButton{ + AssignTo: &openBtn, + Text: "🌐 Открыть в браузере", + OnClicked: func() { + openBrowser(webURL) + }, + }, + Label{Text: "─────────────────────────────────"}, + Label{Text: "Статус Twitch чата:"}, + Label{AssignTo: &chatStatusLabel, Text: "загрузка..."}, + Label{Text: "Статус EventSub:"}, + Label{AssignTo: &eventSubLabel, Text: "загрузка..."}, + Label{Text: "─────────────────────────────────"}, + PushButton{ + AssignTo: &exitBtn, + Text: "❌ Завершить работу бота", + OnClicked: func() { + exitWithoutMinimize = true // запоминаем, что хотим выйти + _ = mw.Close() + }, + }, + }, + }.Create() + + if err != nil { + msg := "Failed to create GUI window: " + err.Error() + logger.Error(msg) + walk.MsgBox(nil, "Ошибка", msg, walk.MsgBoxIconError) + return + } + + // При закрытии окна – либо сворачиваем в трей, либо завершаем программу + mw.Closing().Attach(func(cancel *bool, reason walk.CloseReason) { + if exitWithoutMinimize { + // полное завершение – не отменяем закрытие + return + } + // иначе – сворачиваем в трей + *cancel = true + mw.Hide() + }) + + // Создаём иконку в трее + ni, err := walk.NewNotifyIcon(mw) + if err != nil { + logger.Error("Failed to create notify icon: %v", err) + } else { + _ = ni.SetToolTip("TTW_Bot") + // Устанавливаем иконку (системная иконка информации) + if err := ni.SetIcon(walk.IconInformation()); err != nil { + logger.Warn("Failed to set icon: %v", err) + } + + showAction := walk.NewAction() + _ = showAction.SetText("Показать окно") + showAction.Triggered().Attach(func() { + mw.Show() + mw.SetVisible(true) + }) + exitAction := walk.NewAction() + _ = exitAction.SetText("Выход") + exitAction.Triggered().Attach(func() { + exitWithoutMinimize = true + _ = mw.Close() + }) + menu := ni.ContextMenu() + _ = menu.Actions().Add(showAction) + _ = menu.Actions().Add(exitAction) + + ni.MouseDown().Attach(func(x, y int, button walk.MouseButton) { + if button == walk.LeftButton { + mw.Show() + mw.SetVisible(true) + } + }) + if err := ni.SetVisible(true); err != nil { + logger.Error("Failed to set tray icon visible: %v", err) + } + } + + // Запускаем периодическое обновление статусов + ticker := time.NewTicker(5 * time.Second) + go func() { + for range ticker.C { + mw.Synchronize(func() { + chatConnected := getChatStatus() + if chatConnected { + _ = chatStatusLabel.SetText("✅ Подключен") + } else { + _ = chatStatusLabel.SetText("❌ Отключен") + } + esConnected, subs := getEventSubStatus() + if esConnected { + _ = eventSubLabel.SetText("✅ Подключен (" + itoa(len(subs)) + " подписок)") + } else { + _ = eventSubLabel.SetText("❌ Отключен") + } + }) + } + }() + + mw.Run() + ticker.Stop() +} + +func openBrowser(url string) { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("cmd", "/c", "start", url) + case "darwin": + cmd = exec.Command("open", url) + default: + cmd = exec.Command("xdg-open", url) + } + if err := cmd.Start(); err != nil { + logger.Error("Failed to open browser: %v", err) + walk.MsgBox(nil, "Ошибка", "Не удалось открыть браузер: "+err.Error(), walk.MsgBoxIconError) + } +} + +func itoa(i int) string { + if i == 0 { + return "0" + } + digits := "" + for i > 0 { + digits = string(rune('0'+i%10)) + digits + i /= 10 + } + return digits +} diff --git a/internal/hotkey/hotkey_windows.go b/internal/hotkey/hotkey_windows.go new file mode 100644 index 0000000..e42927b --- /dev/null +++ b/internal/hotkey/hotkey_windows.go @@ -0,0 +1,120 @@ +package hotkey + +import ( + "fmt" + "runtime" + "strings" + "syscall" + "unsafe" +) + +var ( + user32 = syscall.NewLazyDLL("user32.dll") + procSendInput = user32.NewProc("SendInput") +) + +const ( + INPUT_KEYBOARD = 1 + KEYEVENTF_KEYDOWN = 0x0000 + KEYEVENTF_KEYUP = 0x0002 +) + +type INPUT struct { + Type uint32 + Ki KEYBDINPUT +} + +type KEYBDINPUT struct { + WVirtKey uint16 + WScan uint16 + DwFlags uint32 + Time uint32 + DwExtra uint32 +} + +// Виртуальные коды клавиш +var keyCodes = map[string]uint16{ + "A": 0x41, "B": 0x42, "C": 0x43, "D": 0x44, "E": 0x45, "F": 0x46, "G": 0x47, "H": 0x48, + "I": 0x49, "J": 0x4A, "K": 0x4B, "L": 0x4C, "M": 0x4D, "N": 0x4E, "O": 0x4F, "P": 0x50, + "Q": 0x51, "R": 0x52, "S": 0x53, "T": 0x54, "U": 0x55, "V": 0x56, "W": 0x57, "X": 0x58, + "Y": 0x59, "Z": 0x5A, + "0": 0x30, "1": 0x31, "2": 0x32, "3": 0x33, "4": 0x34, "5": 0x35, "6": 0x36, "7": 0x37, "8": 0x38, "9": 0x39, + "F1": 0x70, "F2": 0x71, "F3": 0x72, "F4": 0x73, "F5": 0x74, "F6": 0x75, "F7": 0x76, "F8": 0x77, + "F9": 0x78, "F10": 0x79, "F11": 0x7A, "F12": 0x7B, + "SPACE": 0x20, "ENTER": 0x0D, "TAB": 0x09, "ESC": 0x1B, + "SHIFT": 0x10, "CTRL": 0x11, "ALT": 0x12, "WIN": 0x5B, + "LEFT": 0x25, "UP": 0x26, "RIGHT": 0x27, "DOWN": 0x28, +} + +var modCodes = map[string]uint16{ + "ALT": 0x12, + "CTRL": 0x11, + "SHIFT": 0x10, + "WIN": 0x5B, +} + +var enabled bool = false + +func Init() error { + if runtime.GOOS != "windows" { + return fmt.Errorf("hotkey emulation only supported on Windows") + } + enabled = true + return nil +} + +func SetEnabled(e bool) { + enabled = e +} + +func PressCombination(combination string) error { + if !enabled { + return fmt.Errorf("hotkey emulation disabled by user") + } + parts := strings.Split(combination, "+") + if len(parts) == 0 { + return fmt.Errorf("invalid combination") + } + + var mods []uint16 + var mainKey uint16 + + for _, p := range parts { + upper := strings.ToUpper(p) + if code, ok := modCodes[upper]; ok { + mods = append(mods, code) + } else if code, ok := keyCodes[upper]; ok { + mainKey = code + } else { + return fmt.Errorf("unknown key: %s", p) + } + } + if mainKey == 0 { + return fmt.Errorf("no main key found") + } + + for _, mod := range mods { + pressKey(mod, true) + } + pressKey(mainKey, true) + pressKey(mainKey, false) + for i := len(mods) - 1; i >= 0; i-- { + pressKey(mods[i], false) + } + return nil +} + +func pressKey(vk uint16, down bool) { + var flags uint32 = KEYEVENTF_KEYDOWN + if !down { + flags = KEYEVENTF_KEYUP + } + input := INPUT{ + Type: INPUT_KEYBOARD, + Ki: KEYBDINPUT{ + WVirtKey: vk, + DwFlags: flags, + }, + } + _, _, _ = procSendInput.Call(1, uintptr(unsafe.Pointer(&input)), unsafe.Sizeof(input)) +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go new file mode 100644 index 0000000..3770f8e --- /dev/null +++ b/internal/logger/logger.go @@ -0,0 +1,165 @@ +package logger + +import ( + "fmt" + "log" + "os" + "sync" + "time" +) + +type LogLevel string + +const ( + LevelInfo LogLevel = "INFO" + LevelWarn LogLevel = "WARN" + LevelError LogLevel = "ERROR" + LevelFatal LogLevel = "FATAL" + LevelDebug LogLevel = "DEBUG" +) + +type LogEntry struct { + Time time.Time `json:"time"` + Level LogLevel `json:"level"` + Message string `json:"message"` +} + +var ( + mu sync.Mutex + // Кольцевой буфер последних записей (максимум 1000) + buffer []LogEntry + bufferIdx int + bufferCap = 1000 + // Канал для подписчиков (один на всех, но можно расширить) + subscribers []chan LogEntry + subMu sync.RWMutex +) + +func Init(filename string) error { + buffer = make([]LogEntry, bufferCap) + bufferIdx = 0 + return nil +} + +// Добавляет запись в буфер и рассылает подписчикам +func addEntry(level LogLevel, format string, v ...interface{}) { + msg := fmt.Sprintf(format, v...) + entry := LogEntry{ + Time: time.Now(), + Level: level, + Message: msg, + } + // Сохраняем в буфер + mu.Lock() + buffer[bufferIdx] = entry + bufferIdx = (bufferIdx + 1) % bufferCap + mu.Unlock() + + // Отправляем подписчикам (асинхронно, чтобы не блокировать) + subMu.RLock() + for _, ch := range subscribers { + select { + case ch <- entry: + default: + // Если канал заполнен, пропускаем (чтобы не тормозить) + } + } + subMu.RUnlock() + + // Вывод в консоль + log.Printf("[%s] %s", level, msg) + + if level == LevelFatal { + os.Exit(1) + } +} + +func Info(format string, v ...interface{}) { + addEntry(LevelInfo, format, v...) +} + +func Warn(format string, v ...interface{}) { + addEntry(LevelWarn, format, v...) +} + +func Error(format string, v ...interface{}) { + addEntry(LevelError, format, v...) +} + +func Fatal(format string, v ...interface{}) { + addEntry(LevelFatal, format, v...) +} + +// GetRecent возвращает последние N записей (в хронологическом порядке) +func GetRecent(limit int) []LogEntry { + if limit > bufferCap { + limit = bufferCap + } + mu.Lock() + defer mu.Unlock() + result := make([]LogEntry, 0, limit) + // Буфер заполнен циклически, начинаем с bufferIdx-1 и идём назад + start := bufferIdx - 1 + if start < 0 { + start = bufferCap - 1 + } + for i := 0; i < limit; i++ { + idx := (start - i + bufferCap) % bufferCap + if buffer[idx].Time.IsZero() { + break + } + result = append([]LogEntry{buffer[idx]}, result...) + } + return result +} + +// Subscribe возвращает канал для получения новых записей (буферизированный) +func Subscribe() <-chan LogEntry { + ch := make(chan LogEntry, 100) + subMu.Lock() + subscribers = append(subscribers, ch) + subMu.Unlock() + return ch +} + +// Unsubscribe удаляет канал из списка подписчиков +func Unsubscribe(ch <-chan LogEntry) { + subMu.Lock() + defer subMu.Unlock() + for i, c := range subscribers { + if c == ch { + subscribers = append(subscribers[:i], subscribers[i+1:]...) + close(c) + break + } + } +} + +func Debug(format string, v ...interface{}) { + addEntry(LevelDebug, format, v...) +} + +// GetAll возвращает все имеющиеся записи в порядке от старых к новым. +func GetAll() []LogEntry { + mu.Lock() + defer mu.Unlock() + result := make([]LogEntry, 0, bufferCap) + // Начинаем с самого старого: индекс (bufferIdx) - это место, куда будет записана следующая запись. + // Самый старый элемент находится в bufferIdx, если буфер заполнен, иначе в 0. + start := 0 + if buffer[bufferCap-1].Time.IsZero() { + // Буфер не полностью заполнен, начинаем с 0 + start = 0 + } else { + // Буфер заполнен, начинаем с bufferIdx (следующая позиция записи — это самое старое) + start = bufferIdx + } + for i := 0; i < bufferCap; i++ { + idx := (start + i) % bufferCap + if buffer[idx].Time.IsZero() { + continue + } + result = append(result, buffer[idx]) + } + return result +} diff --git a/internal/notifications/manager.go b/internal/notifications/manager.go new file mode 100644 index 0000000..f3c1821 --- /dev/null +++ b/internal/notifications/manager.go @@ -0,0 +1,66 @@ +package notifications + +import ( + "stream-bot/internal/audio" // вместо player + "stream-bot/internal/db" + "sync" +) + +type Manager struct { + mu sync.RWMutex + settings map[string]*db.NotificationSetting +} + +func NewManager() (*Manager, error) { + m := &Manager{ + settings: make(map[string]*db.NotificationSetting), + } + if err := m.load(); err != nil { + return nil, err + } + return m, nil +} + +func (m *Manager) load() error { + settings, err := db.GetAllNotificationSettings() + if err != nil { + return err + } + m.mu.Lock() + defer m.mu.Unlock() + for _, s := range settings { + m.settings[s.EventName] = &s + } + return nil +} + +func (m *Manager) PlayEvent(eventName string) error { + m.mu.RLock() + setting, ok := m.settings[eventName] + m.mu.RUnlock() + if !ok || !setting.Enabled || setting.SoundFile == "" { + return nil + } + // Используем audio.PlayWithVolume с громкостью из настроек + return audio.PlayWithVolume(setting.SoundFile, setting.Volume) +} + +func (m *Manager) UpdateSetting(ns *db.NotificationSetting) error { + if err := db.SaveNotificationSetting(ns); err != nil { + return err + } + m.mu.Lock() + m.settings[ns.EventName] = ns + m.mu.Unlock() + return nil +} + +func (m *Manager) GetAll() []db.NotificationSetting { + m.mu.RLock() + defer m.mu.RUnlock() + result := make([]db.NotificationSetting, 0, len(m.settings)) + for _, v := range m.settings { + result = append(result, *v) + } + return result +} diff --git a/internal/parser/parser.go b/internal/parser/parser.go new file mode 100644 index 0000000..990692c --- /dev/null +++ b/internal/parser/parser.go @@ -0,0 +1,232 @@ +package parser + +import ( + "fmt" + "math/rand" + "regexp" + "strconv" + "strings" + "time" +) + +func init() { + rand.Seed(time.Now().UnixNano()) +} + +// ParseTemplate возвращает текст, список звуков и количество минут для таймаута (0 если нет) +func ParseTemplate(template string, username string, args string, aiResult string, getRandomUsername func() string) (result string, soundFiles []string, timeoutMinutes int, err error) { + // Замена простых переменных + result = strings.ReplaceAll(template, "", "@"+username) + result = strings.ReplaceAll(result, "", getRandomUsername()) + result = strings.ReplaceAll(result, "", args) + result = strings.ReplaceAll(result, "", aiResult) + + // Обработка , , + result, soundFiles, timeoutMinutes = processTags(result) + + // Рекурсивная обработка всех + result, err = processGroups(result) + if err != nil { + return "", nil, 0, err + } + result = strings.ReplaceAll(result, "\n", " ") + result = strings.ReplaceAll(result, "\r", " ") + result = regexp.MustCompile(`\s+`).ReplaceAllString(result, " ") + result = strings.TrimSpace(result) + return result, soundFiles, timeoutMinutes, nil +} + +func getRandomViewer() string { + names := []string{"зрителя", "кого-то", "случайного пользователя", "незнакомца"} + return names[rand.Intn(len(names))] +} + +// processTags обрабатывает теги , , +func processTags(text string) (string, []string, int) { + // + randomRe := regexp.MustCompile(``) + text = randomRe.ReplaceAllStringFunc(text, func(m string) string { + sub := randomRe.FindStringSubmatch(m) + s, _ := strconv.Atoi(sub[1]) + e, _ := strconv.Atoi(sub[2]) + if s > e { + s, e = e, s + } + val := rand.Intn(e-s+1) + s + return strconv.Itoa(val) + }) + + // + songRe := regexp.MustCompile(`]+)\s*/>`) + soundFiles := make([]string, 0) + text = songRe.ReplaceAllStringFunc(text, func(m string) string { + fRe := regexp.MustCompile(`f="([^"]+)"`) + matches := fRe.FindStringSubmatch(m) + if len(matches) > 1 { + soundFiles = append(soundFiles, matches[1]) + } + return "" + }) + + // + timeoutMinutes := 0 + timeoutRe := regexp.MustCompile(``) + text = timeoutRe.ReplaceAllStringFunc(text, func(m string) string { + sub := timeoutRe.FindStringSubmatch(m) + if len(sub) > 1 { + minutes, _ := strconv.Atoi(sub[1]) + if minutes > 0 { + timeoutMinutes = minutes + } + } + return "" + }) + + return text, soundFiles, timeoutMinutes +} + +func processRandomAndSong(text string) (string, []string) { + // Обработка + randomRe := regexp.MustCompile(``) + text = randomRe.ReplaceAllStringFunc(text, func(m string) string { + sub := randomRe.FindStringSubmatch(m) + s, _ := strconv.Atoi(sub[1]) + e, _ := strconv.Atoi(sub[2]) + if s > e { + s, e = e, s + } + val := rand.Intn(e-s+1) + s + return strconv.Itoa(val) + }) + + // Обработка с любыми атрибутами + songRe := regexp.MustCompile(`]+)\s*/>`) + soundFiles := make([]string, 0) + text = songRe.ReplaceAllStringFunc(text, func(m string) string { + // Извлекаем значение атрибута f="..." + fRe := regexp.MustCompile(`f="([^"]+)"`) + matches := fRe.FindStringSubmatch(m) + if len(matches) > 1 { + soundFiles = append(soundFiles, matches[1]) + } + return "" // заменяем тег на пустую строку + }) + return text, soundFiles +} + +// processGroups и остальные функции без изменений ... +func processGroups(text string) (string, error) { + var err error + for { + text, err = processOneGroup(text) + if err != nil { + return "", err + } + if !strings.Contains(text, "") { + break + } + } + return text, nil +} + +func processOneGroup(text string) (string, error) { + start := strings.Index(text, "") + if start == -1 { + return text, nil + } + end := findMatchingClosingTag(text, start) + if end == -1 { + return "", fmt.Errorf("unclosed at position %d", start) + } + inner := text[start+7 : end] + sections := extractGSections(inner) + if len(sections) == 0 { + return "", fmt.Errorf("no sections inside at position %d", start) + } + chosen := sections[rand.Intn(len(sections))] + newText := text[:start] + chosen + text[end+8:] + return newText, nil +} + +// findMatchingClosingTag ищет позицию закрывающего с учётом вложенности +func findMatchingClosingTag(text string, start int) int { + depth := 1 + i := start + 7 + for i < len(text) { + if strings.HasPrefix(text[i:], "") { + depth++ + i += 7 + } else if strings.HasPrefix(text[i:], "") { + depth-- + if depth == 0 { + return i + } + i += 8 + } else { + i++ + } + } + return -1 +} + +// extractGSections извлекает содержимое всех ... на верхнем уровне (не внутри вложенных групп) +func extractGSections(s string) []string { + var result []string + i := 0 + for i < len(s) { + if strings.HasPrefix(s[i:], "") { + startContent := i + 3 + j := startContent + depth := 0 + for j < len(s) { + if strings.HasPrefix(s[j:], "") { + depth++ + j += 7 + } else if strings.HasPrefix(s[j:], "") { + if depth > 0 { + depth-- + } + j += 8 + } else if strings.HasPrefix(s[j:], "") && depth == 0 { + content := s[startContent:j] + result = append(result, content) + i = j + 4 + break + } else { + j++ + } + } + if j >= len(s) { + break + } + } else if strings.HasPrefix(s[i:], "") { + // Пропускаем вложенную группу целиком + groupEnd := findMatchingClosingTag(s, i) + if groupEnd == -1 { + break + } + i = groupEnd + 8 + } else { + i++ + } + } + return result +} + +// ValidateTemplate проверяет баланс тегов +func ValidateTemplate(template string) error { + balance := 0 + for i := 0; i < len(template); i++ { + if strings.HasPrefix(template[i:], "") { + balance++ + i += 6 + } else if strings.HasPrefix(template[i:], "") { + balance-- + i += 7 + } + } + if balance != 0 { + return fmt.Errorf("unbalanced tags") + } + return nil +} diff --git a/internal/platforms/manager.go b/internal/platforms/manager.go new file mode 100644 index 0000000..db86551 --- /dev/null +++ b/internal/platforms/manager.go @@ -0,0 +1,213 @@ +package platforms + +import ( + "fmt" + "stream-bot/internal/commands" + "stream-bot/internal/db" + "stream-bot/internal/events" + "stream-bot/internal/logger" + "stream-bot/internal/notifications" + "stream-bot/internal/userstats" + "stream-bot/internal/webservices" + "strings" + "sync" + "time" +) + +type Platform interface { + Connect() error + Disconnect() + SendMessage(text string) error + GetName() string +} + +type Manager struct { + platforms map[string]Platform + cmdProc *commands.Processor + eventProc *events.Processor + mu sync.RWMutex + userStats *userstats.Store + notifMgr *notifications.Manager + webServices *webservices.Manager +} + +func NewManager(cmdProc *commands.Processor, eventProc *events.Processor, notifMgr *notifications.Manager, webSrv *webservices.Manager, twitchClientID, twitchClientSecret string) *Manager { + m := &Manager{ + platforms: make(map[string]Platform), + cmdProc: cmdProc, + eventProc: eventProc, + userStats: userstats.NewStore(), + notifMgr: notifMgr, + webServices: webSrv, + } + m.platforms["twitch"] = NewTwitchPlatform(m, twitchClientID, twitchClientSecret) + return m +} + +func (m *Manager) ConnectAll() { + for name, p := range m.platforms { + if err := p.Connect(); err != nil { + logger.Error("Failed to connect %s: %v", name, err) + } + } +} + +func (m *Manager) StopAll() { + for _, p := range m.platforms { + p.Disconnect() + } +} + +func (m *Manager) GetPlatform(name string) Platform { + m.mu.RLock() + defer m.mu.RUnlock() + return m.platforms[name] +} + +func (m *Manager) OnChatMessage(platform, channel, username, message string, isMod, isBroadcaster, isVip, isSubscriber bool) { + // Обновляем статистику пользователя + m.userStats.Update(username, func(u *userstats.UserStats) { + u.MessageCount++ + u.LastActive = time.Now() + u.IsMod = isMod + u.IsVip = isVip + u.IsSubscriber = isSubscriber + }) + if m.notifMgr != nil { + _ = m.notifMgr.PlayEvent("new_message") + } + m.webServices.SendChatMessage(webservices.ChatMessage{ + Username: username, + Message: message, + IsMod: isMod, + IsVip: isVip, + IsSub: isSubscriber, + Timestamp: time.Now().Unix(), + }) + // Обработка команды + if len(message) == 0 || message[0] != '!' { + // Проверка на отметку: если пользователь отмечен и это его первое сообщение за стрим (сегодня) + m.checkAndSendMarkNotification(username, platform, channel) + return + } + + parts := strings.SplitN(message, " ", 2) + trigger := strings.TrimPrefix(parts[0], "!") + args := "" + if len(parts) > 1 { + args = parts[1] + } + resp, _, err := m.cmdProc.ProcessCommand(trigger, username, platform, isMod, isBroadcaster, args) + if err != nil { + logger.Error("Command error: %v", err) + return + } + if resp != "" { + if p := m.GetPlatform(platform); p != nil { + _ = p.SendMessage(resp) + } + } + // После команды тоже проверяем отметку (можно вынести в общее место) + m.checkAndSendMarkNotification(username, platform, channel) +} + +// checkAndSendMarkNotification отправляет сообщение, если пользователь отмечен и сегодня ещё не отмечали +func (m *Manager) checkAndSendMarkNotification(username, platform, channel string) { + marked, lastDate, err := db.IsUserMarked(username, platform) + if err != nil { + logger.Error("Failed to check marked user: %v", err) + return + } + if !marked { + return + } + today := time.Now().Format("2006-01-02") + if lastDate == today { + return + } + // Отправляем сообщение с упоминанием пользователя, а не канала + msg := fmt.Sprintf("Время кое-кого отметить! Отмечен @%s", username) + if p := m.GetPlatform(platform); p != nil { + _ = p.SendMessage(msg) + } + _ = db.UpdateMarkedUserDate(username, platform, time.Now()) +} + +func (m *Manager) OnEvent(platform, eventName string, params map[string]string) { + m.eventProc.ProcessEvent(platform, eventName, params) + if m.webServices != nil { + // Преобразуем map[string]string в map[string]interface{} + data := make(map[string]interface{}) + for k, v := range params { + data[k] = v + } + // Исправленный вызов: + m.webServices.SendAlertEvent(webservices.AlertEvent{ + Type: eventName, + Data: data, + }) + } +} + +func (m *Manager) IsConnected(platform string) bool { + m.mu.RLock() + defer m.mu.RUnlock() + if p, ok := m.platforms[platform]; ok { + if tw, ok := p.(*TwitchPlatform); ok { + return tw.IsConnected() + } + // для других платформ можно добавить аналогично + } + return false +} + +func (m *Manager) GetAllUsers() []*userstats.UserStats { + return m.userStats.GetAll() +} + +func (m *Manager) UpdateUserFlags(username string, isVip, isMod, isSubscriber bool) { + m.userStats.Update(username, func(u *userstats.UserStats) { + if isVip { + u.IsVip = isVip + } + if isMod { + u.IsMod = isMod + } + if isSubscriber { + u.IsSubscriber = isSubscriber + } + }) +} + +func (m *Manager) UpdateUserMarked(username string, marked bool) { + m.userStats.Update(username, func(u *userstats.UserStats) { + u.IsMarked = marked + }) +} + +func (m *Manager) SetVip(username string, isVip bool) { + m.userStats.Update(username, func(u *userstats.UserStats) { + u.IsVip = isVip + }) +} + +func (m *Manager) SetMod(username string, isMod bool) { + m.userStats.Update(username, func(u *userstats.UserStats) { + u.IsMod = isMod + }) +} + +func (m *Manager) SetMarked(username string, isMarked bool) { + m.userStats.Update(username, func(u *userstats.UserStats) { + u.IsMarked = isMarked + }) +} + +func (m *Manager) GetTwitchEventSubStatus() (connected bool, subscriptions []string, err error) { + tw, ok := m.platforms["twitch"].(*TwitchPlatform) + if !ok { + return false, nil, fmt.Errorf("twitch platform not available") + } + connected, subscriptions = tw.EventSubStatus() + return connected, subscriptions, nil +} diff --git a/internal/platforms/twitch.go b/internal/platforms/twitch.go new file mode 100644 index 0000000..dfc2634 --- /dev/null +++ b/internal/platforms/twitch.go @@ -0,0 +1,234 @@ +package platforms + +import ( + "fmt" + "stream-bot/internal/db" + "stream-bot/internal/logger" + "stream-bot/internal/twitchapi" + "strings" + "sync" + + "github.com/gempir/go-twitch-irc/v4" +) + +type TwitchPlatform struct { + client *twitch.Client + manager *Manager + channel string + botLogin string + connected bool + mu sync.RWMutex + eventSub *TwitchEventSub + twitchAPI *twitchapi.TwitchAPI +} + +func NewTwitchPlatform(mgr *Manager, clientID, clientSecret string) *TwitchPlatform { + twitchAPI := twitchapi.New(clientID, clientSecret) + return &TwitchPlatform{ + manager: mgr, + twitchAPI: twitchAPI, + } +} + +func (t *TwitchPlatform) GetName() string { + return "twitch" +} + +func (t *TwitchPlatform) Connect() error { + tokens, err := db.GetPlatformTokens("twitch") + if err != nil || tokens == nil || tokens.BotToken == "" { + logger.Warn("Twitch bot token not set, skipping connection") + return fmt.Errorf("no bot token") + } + t.botLogin = tokens.BotLogin + if t.botLogin == "" { + t.botLogin = "justinfan123" + } + t.channel = tokens.UserLogin + if t.channel == "" { + logger.Warn("Twitch user login not set, cannot join channel") + return fmt.Errorf("no channel name") + } + + t.client = twitch.NewClient(t.botLogin, "oauth:"+tokens.BotToken) + t.client.OnPrivateMessage(func(msg twitch.PrivateMessage) { + badges := msg.Tags["badges"] + isMod := strings.Contains(badges, "moderator/1") + isVip := strings.Contains(badges, "vip/1") + isSubscriber := strings.Contains(badges, "subscriber/") + isBroadcaster := strings.Contains(badges, "broadcaster/1") + + t.manager.OnChatMessage("twitch", msg.Channel, msg.User.Name, msg.Message, isMod, isBroadcaster, isVip, isSubscriber) + }) + + t.client.Join(t.channel) + go func() { + _ = t.client.Connect() + }() + + t.mu.Lock() + t.connected = true + t.mu.Unlock() + + // EventSub использует уже существующий twitchAPI + t.eventSub = NewTwitchEventSub(t.manager, t.twitchAPI) + if err := t.eventSub.Start(); err != nil { + logger.Warn("Failed to start EventSub: %v", err) + } + return nil +} + +func (t *TwitchPlatform) Disconnect() { + t.mu.Lock() + defer t.mu.Unlock() + if t.eventSub != nil { + t.eventSub.Stop() + } + if t.client != nil { + t.client.Disconnect() + } + t.connected = false +} + +func (t *TwitchPlatform) IsConnected() bool { + t.mu.RLock() + defer t.mu.RUnlock() + return t.connected +} + +func (t *TwitchPlatform) SendMessage(text string) error { + if t.client == nil { + return fmt.Errorf("not connected") + } + t.client.Say(t.channel, text) + return nil +} + +func (t *TwitchPlatform) TimeoutUser(username string, seconds int) { + t.client.Say(t.channel, fmt.Sprintf("/timeout %s %d", username, seconds)) +} + +func (t *TwitchPlatform) BanUser(username string) { + t.client.Say(t.channel, fmt.Sprintf("/ban %s", username)) +} + +func (t *TwitchPlatform) UnbanUser(username string) { + t.client.Say(t.channel, fmt.Sprintf("/unban %s", username)) +} + +func (t *TwitchPlatform) AddVip(username string) { + t.client.Say(t.channel, fmt.Sprintf("/vip %s", username)) +} + +func (t *TwitchPlatform) RemoveVip(username string) { + t.client.Say(t.channel, fmt.Sprintf("/unvip %s", username)) +} + +func (t *TwitchPlatform) AddMod(username string) { + t.client.Say(t.channel, fmt.Sprintf("/mod %s", username)) +} + +func (t *TwitchPlatform) RemoveMod(username string) { + t.client.Say(t.channel, fmt.Sprintf("/unmod %s", username)) +} + +// GetClientID удалён – используем t.twitchAPI.GetClientID() при необходимости + +func (t *TwitchPlatform) EventSubStatus() (connected bool, subscriptions []string) { + if t.eventSub == nil { + return false, nil + } + return t.eventSub.IsConnected(), t.eventSub.GetSubscriptions() +} + +// TimeoutUserViaAPI таймаут через API +func (t *TwitchPlatform) TimeoutUserViaAPI(username string, seconds int) error { + broadcasterID, err := t.twitchAPI.GetBroadcasterID() + if err != nil { + return err + } + moderatorID := broadcasterID + userID, err := t.twitchAPI.GetUserID(username) + if err != nil { + return err + } + return t.twitchAPI.TimeoutUser(broadcasterID, moderatorID, userID, seconds) +} + +// BanUserViaAPI бан через API +func (t *TwitchPlatform) BanUserViaAPI(username string) error { + broadcasterID, err := t.twitchAPI.GetBroadcasterID() + if err != nil { + return err + } + userID, err := t.twitchAPI.GetUserID(username) + if err != nil { + return err + } + return t.twitchAPI.BanUser(broadcasterID, broadcasterID, userID) +} + +// UnbanUserViaAPI разбан через API +func (t *TwitchPlatform) UnbanUserViaAPI(username string) error { + broadcasterID, err := t.twitchAPI.GetBroadcasterID() + if err != nil { + return err + } + userID, err := t.twitchAPI.GetUserID(username) + if err != nil { + return err + } + return t.twitchAPI.UnbanUser(broadcasterID, broadcasterID, userID) +} + +// AddVipViaAPI добавить VIP +func (t *TwitchPlatform) AddVipViaAPI(username string) error { + broadcasterID, err := t.twitchAPI.GetBroadcasterID() + if err != nil { + return err + } + userID, err := t.twitchAPI.GetUserID(username) + if err != nil { + return err + } + return t.twitchAPI.AddVip(broadcasterID, userID) +} + +// RemoveVipViaAPI удалить VIP +func (t *TwitchPlatform) RemoveVipViaAPI(username string) error { + broadcasterID, err := t.twitchAPI.GetBroadcasterID() + if err != nil { + return err + } + userID, err := t.twitchAPI.GetUserID(username) + if err != nil { + return err + } + return t.twitchAPI.RemoveVip(broadcasterID, userID) +} + +// AddModViaAPI добавить модератора +func (t *TwitchPlatform) AddModViaAPI(username string) error { + broadcasterID, err := t.twitchAPI.GetBroadcasterID() + if err != nil { + return err + } + userID, err := t.twitchAPI.GetUserID(username) + if err != nil { + return err + } + return t.twitchAPI.AddMod(broadcasterID, userID) +} + +// RemoveModViaAPI удалить модератора +func (t *TwitchPlatform) RemoveModViaAPI(username string) error { + broadcasterID, err := t.twitchAPI.GetBroadcasterID() + if err != nil { + return err + } + userID, err := t.twitchAPI.GetUserID(username) + if err != nil { + return err + } + return t.twitchAPI.RemoveMod(broadcasterID, userID) +} diff --git a/internal/platforms/twitch_auth.go b/internal/platforms/twitch_auth.go new file mode 100644 index 0000000..69f6d41 --- /dev/null +++ b/internal/platforms/twitch_auth.go @@ -0,0 +1,134 @@ +package platforms + +import ( + "context" + "fmt" + "net/http" + "stream-bot/internal/logger" + "time" +) + +type TwitchAuth struct { + clientID string + clientSecret string + redirectURI string + server *http.Server + waitCh chan string +} + +func NewTwitchAuth(clientID, clientSecret string) *TwitchAuth { + return &TwitchAuth{ + clientID: clientID, + clientSecret: clientSecret, + redirectURI: "http://localhost:8089", + waitCh: make(chan string, 1), + } +} + +func (ta *TwitchAuth) GenerateAuthURL(scope []string, state string) string { + url := "https://id.twitch.tv/oauth2/authorize?" + + "client_id=" + ta.clientID + + "&redirect_uri=" + ta.redirectURI + + "&response_type=token" + + "&scope=" + scopeString(scope) + + "&state=" + state + return url +} + +func scopeString(scopes []string) string { + s := "" + for i, sc := range scopes { + if i > 0 { + s += "+" + } + s += sc + } + return s +} + +func (ta *TwitchAuth) StartTempServer() error { + if ta.server != nil { + return nil + } + mux := http.NewServeMux() + mux.HandleFunc("/", ta.handleCallback) + ta.server = &http.Server{ + Addr: ":8089", + Handler: mux, + } + go func() { + if err := ta.server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + logger.Error("OAuth server error: %v", err) + } + }() + return nil +} + +func (ta *TwitchAuth) StopTempServer() { + if ta.server != nil { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = ta.server.Shutdown(ctx) + ta.server = nil + } +} + +func (ta *TwitchAuth) handleCallback(w http.ResponseWriter, r *http.Request) { + html := ` + + + + + Twitch Auth + + + +

Авторизация Twitch

+

Обработка токена...

+ + + + ` + w.Header().Set("Content-Type", "text/html; charset=utf-8") + _, _ = w.Write([]byte(html)) +} + +func (ta *TwitchAuth) WaitForToken(timeout time.Duration) (string, error) { + select { + case token := <-ta.waitCh: + return token, nil + case <-time.After(timeout): + return "", fmt.Errorf("timeout waiting for token") + } +} + +func (ta *TwitchAuth) SetTokenCallback(token string) { + select { + case ta.waitCh <- token: + default: + } +} diff --git a/internal/platforms/twitch_eventsub.go b/internal/platforms/twitch_eventsub.go new file mode 100644 index 0000000..07875f4 --- /dev/null +++ b/internal/platforms/twitch_eventsub.go @@ -0,0 +1,416 @@ +package platforms + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "stream-bot/internal/logger" + "stream-bot/internal/twitchapi" + "sync" + "time" + + "github.com/gorilla/websocket" +) + +// TwitchEventSub реализует клиент EventSub через WebSocket +type TwitchEventSub struct { + manager *Manager + twitchAPI *twitchapi.TwitchAPI + conn *websocket.Conn + sessionID string + subscriptions map[string]bool // eventType -> подписана ли + mu sync.Mutex + stopCh chan struct{} + doneCh chan struct{} + ctx context.Context + cancel context.CancelFunc +} + +// Структуры сообщений EventSub +type eventSubMessage struct { + Metadata struct { + MessageID string `json:"message_id"` + MessageType string `json:"message_type"` // session_welcome, session_keepalive, notification, session_revoke + MessageTimestamp string `json:"message_timestamp"` + SessionID string `json:"session_id,omitempty"` + SubscriptionType string `json:"subscription_type,omitempty"` + SubscriptionVersion string `json:"subscription_version,omitempty"` + } `json:"metadata"` + Payload struct { + Session struct { + ID string `json:"id"` + Status string `json:"status"` + ConnectedAt string `json:"connected_at"` + KeepaliveTimeoutSeconds int `json:"keepalive_timeout_seconds"` + ReconnectURL string `json:"reconnect_url"` + } `json:"session,omitempty"` + Subscription struct { + ID string `json:"id"` + Status string `json:"status"` + Type string `json:"type"` + Version string `json:"version"` + Condition map[string]string `json:"condition"` + Transport struct { + Method string `json:"method"` + SessionID string `json:"session_id"` + } `json:"transport"` + CreatedAt string `json:"created_at"` + } `json:"subscription,omitempty"` + Event json.RawMessage `json:"event,omitempty"` + } `json:"payload"` +} + +func NewTwitchEventSub(manager *Manager, twitchAPI *twitchapi.TwitchAPI) *TwitchEventSub { + return &TwitchEventSub{ + manager: manager, + twitchAPI: twitchAPI, + subscriptions: make(map[string]bool), + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +func (es *TwitchEventSub) Start() error { + es.ctx, es.cancel = context.WithCancel(context.Background()) + logger.Info("Starting Twitch EventSub WebSocket client...") + go es.connect() + return nil +} + +func (es *TwitchEventSub) Stop() { + if es.cancel != nil { + es.cancel() + } + if es.conn != nil { + _ = es.conn.Close() + } + select { + case <-es.doneCh: + logger.Info("Twitch EventSub stopped gracefully") + case <-time.After(3 * time.Second): + logger.Warn("Twitch EventSub stop timeout") + } +} + +func (es *TwitchEventSub) connect() { + defer close(es.doneCh) + for { + select { + case <-es.ctx.Done(): + return + default: + } + + conn, _, err := websocket.DefaultDialer.Dial("wss://eventsub.wss.twitch.tv/ws", nil) + if err != nil { + logger.Error("EventSub WebSocket dial error: %v, reconnecting...", err) + select { + case <-es.ctx.Done(): + return + case <-time.After(5 * time.Second): + continue + } + } + es.mu.Lock() + es.conn = conn + es.mu.Unlock() + + err = es.readLoop(conn) + if err != nil { + logger.Error("EventSub read loop error: %v, reconnecting...", err) + _ = conn.Close() + select { + case <-es.ctx.Done(): + return + case <-time.After(5 * time.Second): + continue + } + } + + select { + case <-es.ctx.Done(): + return + default: + } + } +} + +func (es *TwitchEventSub) readLoop(conn *websocket.Conn) error { + for { + select { + case <-es.ctx.Done(): + return es.ctx.Err() + default: + } + _, msg, err := conn.ReadMessage() + if err != nil { + return err + } + + var envelope eventSubMessage + if err := json.Unmarshal(msg, &envelope); err != nil { + logger.Error("Failed to parse EventSub message: %v", err) + continue + } + + switch envelope.Metadata.MessageType { + case "session_welcome": + es.handleWelcome(envelope) + case "session_keepalive": + // Ничего не логируем, чтобы не засорять логи + case "notification": + es.handleNotification(envelope) + case "session_revoke": + logger.Warn("EventSub session revoked, will reconnect") + return fmt.Errorf("session revoked") + default: + logger.Warn("Unknown EventSub message type: %s", envelope.Metadata.MessageType) + } + } +} + +func (es *TwitchEventSub) handleWelcome(msg eventSubMessage) { + es.sessionID = msg.Payload.Session.ID + logger.Info("EventSub connected, session ID: %s", es.sessionID) + + // После получения welcome подписываемся на события + broadcasterID, err := es.twitchAPI.GetBroadcasterID() + if err != nil { + logger.Error("Cannot get broadcaster ID for subscriptions: %v", err) + return + } + + // Список событий для подписки + subscriptions := []struct { + Type string + Version string + Condition map[string]string + }{ + { + Type: "channel.follow", + Version: "2", + Condition: map[string]string{ + "broadcaster_user_id": broadcasterID, + "moderator_user_id": broadcasterID, // используем ID стримера как модератора + }, + }, + { + Type: "channel.subscribe", + Version: "1", + Condition: map[string]string{ + "broadcaster_user_id": broadcasterID, + }, + }, + { + Type: "channel.subscription.gift", + Version: "1", + Condition: map[string]string{ + "broadcaster_user_id": broadcasterID, + }, + }, + { + Type: "channel.raid", + Version: "1", + Condition: map[string]string{ + "to_broadcaster_user_id": broadcasterID, + }, + }, + { + Type: "channel.channel_points_custom_reward_redemption.add", + Version: "1", + Condition: map[string]string{ + "broadcaster_user_id": broadcasterID, + }, + }, + } + + for _, sub := range subscriptions { + if err := es.subscribe(sub.Type, sub.Version, sub.Condition); err != nil { + logger.Error("Failed to subscribe to %s: %v", sub.Type, err) + } else { + es.mu.Lock() + es.subscriptions[sub.Type] = true + es.mu.Unlock() + logger.Info("Subscribed to %s", sub.Type) + } + } +} + +// subscribe создаёт подписку через API Twitch, используя токен стримера (user_token) +func (es *TwitchEventSub) subscribe(eventType, version string, condition map[string]string) error { + // Берём токен стримера (user_token), т.к. для подписки на follow нужны права модератора + token, err := es.twitchAPI.GetUserToken() // добавим этот метод в twitchapi + if err != nil { + return err + } + clientID := es.twitchAPI.GetClientID() + + subReq := struct { + Type string `json:"type"` + Version string `json:"version"` + Condition map[string]string `json:"condition"` + Transport struct { + Method string `json:"method"` + SessionID string `json:"session_id"` + } `json:"transport"` + }{ + Type: eventType, + Version: version, + Condition: condition, + Transport: struct { + Method string `json:"method"` + SessionID string `json:"session_id"` + }{ + Method: "websocket", + SessionID: es.sessionID, + }, + } + + body, _ := json.Marshal(subReq) + url := "https://api.twitch.tv/helix/eventsub/subscriptions" + req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", clientID) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + + if resp.StatusCode != 202 { + errBody, _ := io.ReadAll(resp.Body) + logger.Error("Twitch API error when subscribing to %s: status %d, body: %s", eventType, resp.StatusCode, string(errBody)) + return fmt.Errorf("subscription failed: %d %s", resp.StatusCode, string(errBody)) + } + logger.Info("Subscription to %s created successfully", eventType) + return nil +} + +func (es *TwitchEventSub) handleNotification(msg eventSubMessage) { + eventType := msg.Metadata.SubscriptionType + logger.Info("EventSub notification received: %s", eventType) + + var params map[string]string + + switch eventType { + case "channel.follow": + var data struct { + UserName string `json:"user_name"` + UserLogin string `json:"user_login"` + FollowedAt string `json:"followed_at"` + } + if err := json.Unmarshal(msg.Payload.Event, &data); err != nil { + logger.Error("Failed to parse follow event: %v", err) + return + } + logger.Info("Follow event: %s followed the channel at %s", data.UserName, data.FollowedAt) + params = map[string]string{ + "username": data.UserName, + "user_login": data.UserLogin, + "followed_at": data.FollowedAt, + } + es.manager.OnEvent("twitch", "follow", params) + + case "channel.subscribe": + var data struct { + UserName string `json:"user_name"` + Tier string `json:"tier"` // "1000", "2000", "3000" + IsGift bool `json:"is_gift"` + } + if err := json.Unmarshal(msg.Payload.Event, &data); err != nil { + logger.Error("Failed to parse subscribe event: %v", err) + return + } + logger.Info("Subscribe event: %s subscribed with tier %s (is_gift=%v)", data.UserName, data.Tier, data.IsGift) + params = map[string]string{ + "username": data.UserName, + "tier": data.Tier, + "is_gift": fmt.Sprintf("%t", data.IsGift), + } + es.manager.OnEvent("twitch", "subscribe", params) + + case "channel.subscription.gift": + var data struct { + UserName string `json:"user_name"` // даритель + RecipientName string `json:"recipient_user_name"` + Tier string `json:"tier"` + CumulativeTotal int `json:"cumulative_total"` + } + if err := json.Unmarshal(msg.Payload.Event, &data); err != nil { + logger.Error("Failed to parse gift sub event: %v", err) + return + } + logger.Info("Gift sub event: %s gifted %d subscription(s) (tier %s), recipient: %s", data.UserName, data.CumulativeTotal, data.Tier, data.RecipientName) + params = map[string]string{ + "gifter": data.UserName, + "recipient": data.RecipientName, + "tier": data.Tier, + "cumulative_total": fmt.Sprintf("%d", data.CumulativeTotal), + } + es.manager.OnEvent("twitch", "gift_sub", params) + + case "channel.raid": + var data struct { + FromBroadcasterName string `json:"from_broadcaster_user_name"` + Viewers int `json:"viewers"` + } + if err := json.Unmarshal(msg.Payload.Event, &data); err != nil { + logger.Error("Failed to parse raid event: %v", err) + return + } + logger.Info("Raid event: %s raided with %d viewers", data.FromBroadcasterName, data.Viewers) + params = map[string]string{ + "from": data.FromBroadcasterName, + "viewers": fmt.Sprintf("%d", data.Viewers), + } + es.manager.OnEvent("twitch", "raid", params) + + case "channel.channel_points_custom_reward_redemption.add": + var data struct { + UserName string `json:"user_name"` + Reward struct { + Title string `json:"title"` + Cost int `json:"cost"` + } `json:"reward"` + UserInput string `json:"user_input"` + } + if err := json.Unmarshal(msg.Payload.Event, &data); err != nil { + logger.Error("Failed to parse reward redemption event: %v", err) + return + } + logger.Info("Reward redemption: %s redeemed '%s' (cost %d) with input: %s", data.UserName, data.Reward.Title, data.Reward.Cost, data.UserInput) + params = map[string]string{ + "username": data.UserName, + "reward_title": data.Reward.Title, + "reward_cost": fmt.Sprintf("%d", data.Reward.Cost), + "user_input": data.UserInput, + } + es.manager.OnEvent("twitch", "reward_redemption", params) + + default: + logger.Warn("Unhandled event type: %s", eventType) + } +} + +func (es *TwitchEventSub) IsConnected() bool { + es.mu.Lock() + defer es.mu.Unlock() + return es.conn != nil +} + +func (es *TwitchEventSub) GetSubscriptions() []string { + es.mu.Lock() + defer es.mu.Unlock() + subs := make([]string, 0, len(es.subscriptions)) + for s := range es.subscriptions { + subs = append(subs, s) + } + return subs +} diff --git a/internal/twitchapi/twitchapi.go b/internal/twitchapi/twitchapi.go new file mode 100644 index 0000000..e52e610 --- /dev/null +++ b/internal/twitchapi/twitchapi.go @@ -0,0 +1,430 @@ +package twitchapi + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "stream-bot/internal/db" + "sync" + "time" +) + +type TwitchAPI struct { + client *http.Client + clientID string + clientSecret string + userIDCache sync.Map // map[string]string (login -> userID) + createdAtCache sync.Map // map[string]time.Time (userID -> createdAt) + followCache sync.Map // map[string]time.Time (key: "broadcasterID:userID" -> followedAt) + broadcasterID string + mu sync.RWMutex +} + +func New(clientID, clientSecret string) *TwitchAPI { + return &TwitchAPI{ + client: &http.Client{Timeout: 10 * time.Second}, + clientID: clientID, + clientSecret: clientSecret, + } +} + +// getValidToken возвращает токен бота (или стримера) для API запросов. +// Для получения данных о пользователе и подписках достаточно бота, но для надёжности используем user_token. +func (t *TwitchAPI) getToken() (string, error) { + tokens, err := db.GetPlatformTokens("twitch") + if err != nil || tokens == nil { + return "", fmt.Errorf("no twitch tokens") + } + if tokens.UserToken != "" { + return tokens.UserToken, nil + } + if tokens.BotToken != "" { + return tokens.BotToken, nil + } + return "", fmt.Errorf("no valid token") +} + +// GetBroadcasterID получает ID канала стримера из его логина (UserLogin) и кэширует. +func (t *TwitchAPI) GetBroadcasterID() (string, error) { + if t.broadcasterID != "" { + return t.broadcasterID, nil + } + tokens, err := db.GetPlatformTokens("twitch") + if err != nil || tokens == nil { + return "", fmt.Errorf("no twitch tokens") + } + if tokens.UserLogin == "" { + return "", fmt.Errorf("broadcaster login not set") + } + id, err := t.GetUserID(tokens.UserLogin) + if err != nil { + return "", err + } + t.broadcasterID = id + return id, nil +} + +// GetUserID получает ID пользователя по логину (с кэшированием). +func (t *TwitchAPI) GetUserID(login string) (string, error) { + if cached, ok := t.userIDCache.Load(login); ok { + return cached.(string), nil + } + token, err := t.getToken() + if err != nil { + return "", err + } + url := fmt.Sprintf("https://api.twitch.tv/helix/users?login=%s", login) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", t.clientID) + + resp, err := t.client.Do(req) + if err != nil { + return "", err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("twitch api error: %d", resp.StatusCode) + } + var data struct { + Data []struct { + ID string `json:"id"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", err + } + if len(data.Data) == 0 { + return "", fmt.Errorf("user not found") + } + t.userIDCache.Store(login, data.Data[0].ID) + return data.Data[0].ID, nil +} + +// GetUserCreatedAt возвращает дату регистрации пользователя. +func (t *TwitchAPI) GetUserCreatedAt(userID string) (time.Time, error) { + if cached, ok := t.createdAtCache.Load(userID); ok { + return cached.(time.Time), nil + } + token, err := t.getToken() + if err != nil { + return time.Time{}, err + } + url := fmt.Sprintf("https://api.twitch.tv/helix/users?id=%s", userID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", t.clientID) + + resp, err := t.client.Do(req) + if err != nil { + return time.Time{}, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + if resp.StatusCode != http.StatusOK { + return time.Time{}, fmt.Errorf("twitch api error: %d", resp.StatusCode) + } + var data struct { + Data []struct { + CreatedAt string `json:"created_at"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return time.Time{}, err + } + if len(data.Data) == 0 { + return time.Time{}, fmt.Errorf("user not found") + } + createdAt, err := time.Parse(time.RFC3339, data.Data[0].CreatedAt) + if err != nil { + return time.Time{}, err + } + t.createdAtCache.Store(userID, createdAt) + return createdAt, nil +} + +// GetFollowCreatedAt возвращает дату начала подписки пользователя на канал стримера. +// Если не подписан, возвращает нулевое время и ошибку (можно обработать как "не подписан"). +func (t *TwitchAPI) GetFollowCreatedAt(broadcasterID, userID string) (time.Time, error) { + key := broadcasterID + ":" + userID + if cached, ok := t.followCache.Load(key); ok { + return cached.(time.Time), nil + } + token, err := t.getToken() + if err != nil { + return time.Time{}, err + } + url := fmt.Sprintf("https://api.twitch.tv/helix/channels/followers?broadcaster_id=%s&user_id=%s", broadcasterID, userID) + req, _ := http.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", t.clientID) + + resp, err := t.client.Do(req) + if err != nil { + return time.Time{}, err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + if resp.StatusCode != http.StatusOK { + // Если статус не 200, считаем, что пользователь не подписан + return time.Time{}, fmt.Errorf("not following or API error") + } + var data struct { + Data []struct { + FollowedAt string `json:"followed_at"` + } `json:"data"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return time.Time{}, err + } + if len(data.Data) == 0 { + return time.Time{}, fmt.Errorf("not following") + } + followedAt, err := time.Parse(time.RFC3339, data.Data[0].FollowedAt) + if err != nil { + return time.Time{}, err + } + t.followCache.Store(key, followedAt) + return followedAt, nil +} + +func (t *TwitchAPI) GetClientID() string { + return t.clientID +} + +// FormatDuration возвращает человекочитаемую строку "X лет Y месяцев Z дней" +func FormatDuration(t time.Time) string { + now := time.Now() + diff := now.Sub(t) + years := int(diff.Hours() / 24 / 365) + months := int(diff.Hours()/24/30) % 12 + days := int(diff.Hours()/24) % 30 + + if years == 0 && months == 0 && days == 0 { + return "менее дня" + } + + var parts []string + if years > 0 { + parts = append(parts, fmt.Sprintf("%d %s", years, plural(years, "год", "года", "лет"))) + } + if months > 0 { + parts = append(parts, fmt.Sprintf("%d %s", months, plural(months, "месяц", "месяца", "месяцев"))) + } + if days > 0 { + parts = append(parts, fmt.Sprintf("%d %s", days, plural(days, "день", "дня", "дней"))) + } + return joinRussian(parts) +} + +func plural(n int, one, few, many string) string { + if n%10 == 1 && n%100 != 11 { + return one + } else if n%10 >= 2 && n%10 <= 4 && (n%100 < 10 || n%100 >= 20) { + return few + } else { + return many + } +} + +func joinRussian(parts []string) string { + if len(parts) == 0 { + return "" + } + if len(parts) == 1 { + return parts[0] + } + if len(parts) == 2 { + return parts[0] + " и " + parts[1] + } + return parts[0] + ", " + parts[1] + " и " + parts[2] +} + +// GetToken возвращает действующий токен для API (бот или стример) +func (t *TwitchAPI) GetToken() (string, error) { + return t.getToken() +} + +// GetUserToken возвращает токен стримера (user_token) для API запросов, требующих прав модератора +func (t *TwitchAPI) GetUserToken() (string, error) { + tokens, err := db.GetPlatformTokens("twitch") + if err != nil || tokens == nil { + return "", fmt.Errorf("no twitch tokens") + } + if tokens.UserToken != "" { + return tokens.UserToken, nil + } + return "", fmt.Errorf("no user token available") +} + +// TimeoutUser отправляет пользователя в таймаут на указанное количество секунд +func (t *TwitchAPI) TimeoutUser(broadcasterID, moderatorID, userID string, durationSeconds int) error { + token, err := t.getToken() + if err != nil { + return err + } + url := "https://api.twitch.tv/helix/moderation/bans" + body := map[string]interface{}{ + "broadcaster_id": broadcasterID, + "moderator_id": moderatorID, + "data": map[string]interface{}{ + "user_id": userID, + "duration": durationSeconds, + "reason": "Timeout from bot command", + }, + } + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", t.clientID) + req.Header.Set("Content-Type", "application/json") + + resp, err := t.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("timeout failed: %d %s", resp.StatusCode, string(bodyBytes)) + } + return nil +} + +// BanUser банит пользователя +func (t *TwitchAPI) BanUser(broadcasterID, moderatorID, userID string) error { + return t.TimeoutUser(broadcasterID, moderatorID, userID, 0) // 0 = перманентный бан +} + +// UnbanUser разбанивает пользователя +func (t *TwitchAPI) UnbanUser(broadcasterID, moderatorID, userID string) error { + token, err := t.getToken() + if err != nil { + return err + } + url := fmt.Sprintf("https://api.twitch.tv/helix/moderation/bans?broadcaster_id=%s&moderator_id=%s&user_id=%s", broadcasterID, moderatorID, userID) + req, _ := http.NewRequest("DELETE", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", t.clientID) + + resp, err := t.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("unban failed: %d %s", resp.StatusCode, string(bodyBytes)) + } + return nil +} + +// AddVip добавляет VIP статус +func (t *TwitchAPI) AddVip(broadcasterID, userID string) error { + token, err := t.getToken() + if err != nil { + return err + } + url := "https://api.twitch.tv/helix/channels/vips" + body := map[string]interface{}{ + "broadcaster_id": broadcasterID, + "user_id": userID, + } + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", t.clientID) + req.Header.Set("Content-Type", "application/json") + + resp, err := t.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("add vip failed: %d %s", resp.StatusCode, string(bodyBytes)) + } + return nil +} + +// RemoveVip удаляет VIP статус +func (t *TwitchAPI) RemoveVip(broadcasterID, userID string) error { + token, err := t.getToken() + if err != nil { + return err + } + url := fmt.Sprintf("https://api.twitch.tv/helix/channels/vips?broadcaster_id=%s&user_id=%s", broadcasterID, userID) + req, _ := http.NewRequest("DELETE", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", t.clientID) + + resp, err := t.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("remove vip failed: %d %s", resp.StatusCode, string(bodyBytes)) + } + return nil +} + +// AddMod добавляет модератора +func (t *TwitchAPI) AddMod(broadcasterID, userID string) error { + token, err := t.getToken() + if err != nil { + return err + } + url := "https://api.twitch.tv/helix/moderation/moderators" + body := map[string]interface{}{ + "broadcaster_id": broadcasterID, + "user_id": userID, + } + jsonBody, _ := json.Marshal(body) + req, _ := http.NewRequest("POST", url, bytes.NewReader(jsonBody)) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", t.clientID) + req.Header.Set("Content-Type", "application/json") + + resp, err := t.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("add mod failed: %d %s", resp.StatusCode, string(bodyBytes)) + } + return nil +} + +// RemoveMod удаляет модератора +func (t *TwitchAPI) RemoveMod(broadcasterID, userID string) error { + token, err := t.getToken() + if err != nil { + return err + } + url := fmt.Sprintf("https://api.twitch.tv/helix/moderation/moderators?broadcaster_id=%s&user_id=%s", broadcasterID, userID) + req, _ := http.NewRequest("DELETE", url, nil) + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Client-Id", t.clientID) + + resp, err := t.client.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("remove mod failed: %d %s", resp.StatusCode, string(bodyBytes)) + } + return nil +} diff --git a/internal/userstats/userstats.go b/internal/userstats/userstats.go new file mode 100644 index 0000000..d13f559 --- /dev/null +++ b/internal/userstats/userstats.go @@ -0,0 +1,62 @@ +package userstats + +import ( + "sync" + "time" +) + +type UserStats struct { + Username string `json:"username"` + MessageCount int `json:"message_count"` + LastActive time.Time `json:"last_active"` + IsMod bool `json:"is_mod"` + IsVip bool `json:"is_vip"` + IsSubscriber bool `json:"is_subscriber"` + IsMarked bool `json:"is_marked"` + LastMarkedDate time.Time `json:"-"` +} + +type Store struct { + mu sync.RWMutex + users map[string]*UserStats // key = username (lowercase) +} + +func NewStore() *Store { + return &Store{ + users: make(map[string]*UserStats), + } +} + +func (s *Store) GetOrCreate(username string) *UserStats { + s.mu.Lock() + defer s.mu.Unlock() + lower := username + if u, ok := s.users[lower]; ok { + return u + } + u := &UserStats{Username: username} + s.users[lower] = u + return u +} + +func (s *Store) Update(username string, fn func(*UserStats)) { + s.mu.Lock() + defer s.mu.Unlock() + lower := username + u, ok := s.users[lower] + if !ok { + u = &UserStats{Username: username} + s.users[lower] = u + } + fn(u) +} + +func (s *Store) GetAll() []*UserStats { + s.mu.RLock() + defer s.mu.RUnlock() + res := make([]*UserStats, 0, len(s.users)) + for _, u := range s.users { + res = append(res, u) + } + return res +} diff --git a/internal/webservices/alert.go b/internal/webservices/alert.go new file mode 100644 index 0000000..d2ab09d --- /dev/null +++ b/internal/webservices/alert.go @@ -0,0 +1,208 @@ +package webservices + +import ( + "encoding/json" + "fmt" + "html/template" + "net/http" + "stream-bot/internal/db" + "stream-bot/internal/logger" + "strings" +) + +type AlertService struct { + *baseService + config *db.AlertWebConfig + server *http.Server +} + +func NewAlertService(port int, config *db.AlertWebConfig) *AlertService { + return &AlertService{ + baseService: newBaseService(port), + config: config, + } +} + +func (s *AlertService) Start() error { + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleIndex) + mux.HandleFunc("/events", s.handleEvents) + mux.HandleFunc("/notify", s.handleNotify) // новый эндпоинт + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.Dir("data/media")))) + mux.Handle("/sounds/", http.StripPrefix("/sounds/", http.FileServer(http.Dir("data/sounds")))) // для звуков + + s.server = &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: mux} + s.running = true + go func() { + if err := s.server.ListenAndServe(); nil != err && err != http.ErrServerClosed { + logger.Error("Alert service error on port %d: %v", s.port, err) + } + }() + logger.Info("Alert service started on port %d", s.port) + return nil +} + +// handleNotify принимает POST-запросы с уведомлениями +func (s *AlertService) handleNotify(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var data map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + // Отправляем всем подключённым клиентам + s.broadcast(data) + w.WriteHeader(http.StatusOK) +} + +func (s *AlertService) Stop() error { + s.running = false + if s.server != nil { + return s.server.Close() + } + return nil +} + +func (s *AlertService) ReloadConfig(config interface{}) error { + newCfg, ok := config.(*db.AlertWebConfig) + if !ok { + return fmt.Errorf("invalid config type, expected *db.AlertWebConfig") + } + s.config = newCfg + return nil +} + +func (s *AlertService) GetPort() int { return s.port } +func (s *AlertService) GetType() string { return "alert" } +func (s *AlertService) IsRunning() bool { return s.running } + +// SendToClients обрабатывает событие и рассылает его клиентам +func (s *AlertService) SendToClients(event AlertEvent) { + s.mu.RLock() + cfg := s.config + s.mu.RUnlock() + if cfg == nil { + return + } + // Обработка команды со звуком (без визуального уведомления) + if event.Type == "command_sound" && event.Sound != "" { + out := map[string]interface{}{ + "title": "", + "text": "", + "duration": 1, + "image": "", + "sound": event.Sound, + } + s.broadcast(out) + return + } + // Обычные события (follow, subscribe и т.д.) + eventCfg, ok := cfg.Events[event.Type] + if !ok || !eventCfg.Enabled { + return + } + title := replacePlaceholders(eventCfg.TitleTemplate, event.Data) + text := replacePlaceholders(eventCfg.TextTemplate, event.Data) + duration := eventCfg.DurationSec + if duration == 0 { + duration = cfg.DefaultDuration + } + image := eventCfg.ImageFile + if image == "" { + image = cfg.DefaultImage + } + sound := eventCfg.SoundFile + if sound == "" { + sound = cfg.DefaultSound + } + out := map[string]interface{}{ + "title": title, + "text": text, + "duration": duration, + "image": image, + "sound": sound, + } + s.broadcast(out) +} + +func (s *AlertService) handleIndex(w http.ResponseWriter, _ *http.Request) { + tmpl := ` + + + + Alerts Overlay + + + +
+ + +` + t := template.Must(template.New("alert").Parse(tmpl)) + _ = t.Execute(w, nil) +} + +func (s *AlertService) handleEvents(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + return + } + clientChan := make(chan interface{}, 100) + s.clientsMu.Lock() + s.clients[clientChan] = true + s.clientsMu.Unlock() + defer func() { + s.clientsMu.Lock() + delete(s.clients, clientChan) + s.clientsMu.Unlock() + close(clientChan) + }() + for { + select { + case data := <-clientChan: + jsonData, _ := json.Marshal(data) + _, _ = fmt.Fprintf(w, "data: %s\n\n", jsonData) + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + +func replacePlaceholders(tmpl string, data map[string]interface{}) string { + res := tmpl + for k, v := range data { + res = strings.ReplaceAll(res, "{"+k+"}", fmt.Sprintf("%v", v)) + } + return res +} diff --git a/internal/webservices/base.go b/internal/webservices/base.go new file mode 100644 index 0000000..37bea76 --- /dev/null +++ b/internal/webservices/base.go @@ -0,0 +1,37 @@ +package webservices + +import ( + "sync" +) + +type baseService struct { + port int + running bool + mu sync.RWMutex + clients map[chan interface{}]bool + clientsMu sync.RWMutex +} + +func newBaseService(port int) *baseService { + return &baseService{ + port: port, + clients: make(map[chan interface{}]bool), + } +} + +// Broadcast отправляет данные всем подключённым SSE-клиентам +func (s *baseService) broadcast(data interface{}) { + s.clientsMu.RLock() + defer s.clientsMu.RUnlock() + for ch := range s.clients { + // Убираем default, чтобы отправка была блокирующей, но тогда один медленный клиент может замедлить всех + // Лучше увеличить буфер и оставить default с предупреждением + select { + case ch <- data: + default: + // Если канал заполнен, это проблема клиента, но мы не должны терять сообщения для других клиентов. + // Однако блокировка нежелательна. Увеличим буфер до 500. + + } + } +} diff --git a/internal/webservices/chat.go b/internal/webservices/chat.go new file mode 100644 index 0000000..d8c79fa --- /dev/null +++ b/internal/webservices/chat.go @@ -0,0 +1,154 @@ +package webservices + +import ( + "encoding/json" + "errors" + "fmt" + "html/template" + "net/http" + "stream-bot/internal/db" + "stream-bot/internal/logger" +) + +type ChatService struct { + *baseService + config *db.ChatWebConfig + server *http.Server +} + +func NewChatService(port int, config *db.ChatWebConfig) *ChatService { + return &ChatService{ + baseService: newBaseService(port), + config: config, + } +} + +func (s *ChatService) Start() error { + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleIndex) + mux.HandleFunc("/events", s.handleEvents) + + s.server = &http.Server{Addr: fmt.Sprintf(":%d", s.port), Handler: mux} + s.running = true + go func() { + if err := s.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("Chat service error on port %d: %v", s.port, err) + } + }() + logger.Info("Chat service started on port %d", s.port) + return nil +} + +func (s *ChatService) Stop() error { + s.running = false + if s.server != nil { + return s.server.Close() + } + return nil +} + +func (s *ChatService) ReloadConfig(config interface{}) error { + newCfg, ok := config.(*db.ChatWebConfig) + if !ok { + return fmt.Errorf("invalid config type, expected *db.ChatWebConfig") + } + s.config = newCfg + return nil +} + +func (s *ChatService) GetPort() int { return s.port } +func (s *ChatService) GetType() string { return "chat" } +func (s *ChatService) IsRunning() bool { return s.running } + +// SendToClients отправляет сообщение всем подключённым SSE-клиентам +func (s *ChatService) SendToClients(msg ChatMessage) { + s.broadcast(msg) +} + +func (s *ChatService) handleIndex(w http.ResponseWriter, _ *http.Request) { + tmpl := ` + + + + Chat Overlay + + + +
+ + +` + t := template.Must(template.New("chat").Parse(tmpl)) + _ = t.Execute(w, s.config) +} + +func (s *ChatService) handleEvents(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + return + } + clientChan := make(chan interface{}, 100) + s.clientsMu.Lock() + s.clients[clientChan] = true + s.clientsMu.Unlock() + defer func() { + s.clientsMu.Lock() + delete(s.clients, clientChan) + s.clientsMu.Unlock() + close(clientChan) + }() + for { + select { + case data := <-clientChan: + jsonData, _ := json.Marshal(data) + _, _ = fmt.Fprintf(w, "data: %s\n\n", jsonData) + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} diff --git a/internal/webservices/manager.go b/internal/webservices/manager.go new file mode 100644 index 0000000..078eaa2 --- /dev/null +++ b/internal/webservices/manager.go @@ -0,0 +1,180 @@ +package webservices + +import ( + "fmt" + "stream-bot/internal/db" + "stream-bot/internal/logger" + "sync" +) + +type Manager struct { + services map[int]Service + mu sync.RWMutex + globalMessageChan chan ChatMessage + globalEventChan chan AlertEvent +} + +func NewManager() *Manager { + m := &Manager{ + services: make(map[int]Service), + globalMessageChan: make(chan ChatMessage, 1000), + globalEventChan: make(chan AlertEvent, 1000), + } + m.startDispatchers() + return m +} + +func (m *Manager) startDispatchers() { + go func() { + for msg := range m.globalMessageChan { + m.mu.RLock() + for _, srv := range m.services { + if chatSrv, ok := srv.(*ChatService); ok { + chatSrv.SendToClients(msg) + } + } + m.mu.RUnlock() + } + }() + go func() { + for ev := range m.globalEventChan { + m.mu.RLock() + for _, srv := range m.services { + if alertSrv, ok := srv.(*AlertService); ok { + alertSrv.SendToClients(ev) + } + } + m.mu.RUnlock() + } + }() +} + +func (m *Manager) StartAll() error { + list, err := db.GetAllWebServices() + if err != nil { + return err + } + for _, ws := range list { + if !ws.Enabled { + continue + } + if err := m.startServiceFromDB(ws); err != nil { + logger.Error("Failed to start service %d: %v", ws.ID, err) + } + } + return nil +} + +func (m *Manager) startServiceFromDB(ws db.WebService) error { + var srv Service + switch ws.Type { + case "chat": + cfg, err := ws.GetChatConfig() + if err != nil { + return err + } + srv = NewChatService(ws.Port, cfg) + case "alert": + cfg, err := ws.GetAlertConfig() + if err != nil { + return err + } + srv = NewAlertService(ws.Port, cfg) + default: + return fmt.Errorf("unknown service type: %s", ws.Type) + } + if err := srv.Start(); err != nil { + return err + } + m.mu.Lock() + m.services[ws.ID] = srv + m.mu.Unlock() + _ = db.SetWebServiceRunning(ws.ID, true) + return nil +} + +func (m *Manager) AddService(serviceType string, port int, config interface{}) (int, error) { + id, err := db.CreateWebService(serviceType, port, config) + if err != nil { + return 0, err + } + return id, nil +} + +func (m *Manager) StartService(id int) error { + ws, err := db.GetWebService(id) + if err != nil { + return err + } + return m.startServiceFromDB(*ws) +} + +func (m *Manager) StopService(id int) error { + m.mu.Lock() + srv, ok := m.services[id] + delete(m.services, id) + m.mu.Unlock() + if !ok { + return nil + } + if err := srv.Stop(); err != nil { + return err + } + _ = db.SetWebServiceRunning(id, false) + return nil +} + +func (m *Manager) UpdateConfig(id int, config interface{}) error { + ws, err := db.GetWebService(id) + if err != nil { + return err + } + if err := db.UpdateWebService(id, ws.Port, config, ws.Enabled); err != nil { + return err + } + m.mu.RLock() + srv, ok := m.services[id] + m.mu.RUnlock() + if ok { + return srv.ReloadConfig(config) + } + return nil +} + +func (m *Manager) DeleteService(id int) error { + _ = m.StopService(id) + return db.DeleteWebService(id) +} + +// Эти два метода должны быть ТОЛЬКО ОДИН РАЗ! +func (m *Manager) SendChatMessage(msg ChatMessage) { + select { + case m.globalMessageChan <- msg: + default: + logger.Warn("Chat message buffer full") + } +} + +func (m *Manager) SendAlertEvent(event AlertEvent) { + select { + case m.globalEventChan <- event: + default: + logger.Warn("Alert event buffer full") + } +} + +func (m *Manager) GetService(id int) Service { + m.mu.RLock() + defer m.mu.RUnlock() + return m.services[id] +} + +func (m *Manager) GetAllServices() map[int]Service { + m.mu.RLock() + defer m.mu.RUnlock() + out := make(map[int]Service) + for k, v := range m.services { + out[k] = v + } + return out +} diff --git a/internal/webservices/service.go b/internal/webservices/service.go new file mode 100644 index 0000000..043f3d7 --- /dev/null +++ b/internal/webservices/service.go @@ -0,0 +1,10 @@ +package webservices + +type Service interface { + Start() error + Stop() error + ReloadConfig(config interface{}) error + GetPort() int + GetType() string + IsRunning() bool +} diff --git a/internal/webservices/types.go b/internal/webservices/types.go new file mode 100644 index 0000000..4d163be --- /dev/null +++ b/internal/webservices/types.go @@ -0,0 +1,16 @@ +package webservices + +type ChatMessage struct { + Username string `json:"username"` + Message string `json:"message"` + IsMod bool `json:"is_mod"` + IsVip bool `json:"is_vip"` + IsSub bool `json:"is_sub"` + Timestamp int64 `json:"timestamp"` +} + +type AlertEvent struct { + Type string `json:"type"` + Data map[string]interface{} `json:"data"` + Sound string `json:"sound,omitempty"` // добавлено поле +} diff --git a/internal/webui/server.go b/internal/webui/server.go new file mode 100644 index 0000000..a18d20c --- /dev/null +++ b/internal/webui/server.go @@ -0,0 +1,1309 @@ +package webui + +import ( + "context" + "crypto/rand" + "embed" + "encoding/base64" + "encoding/json" + "fmt" + "html/template" + "io" + "io/fs" + "mime/multipart" + "net/http" + "os" + "path/filepath" + "strconv" + "stream-bot/internal/ai" + "stream-bot/internal/commands" + "stream-bot/internal/db" + "stream-bot/internal/events" + "stream-bot/internal/hotkey" + "stream-bot/internal/logger" + "stream-bot/internal/notifications" + "stream-bot/internal/parser" + "stream-bot/internal/platforms" + "stream-bot/internal/webservices" + "strings" + "sync" + "time" +) + +var ( + authStates sync.Map // map[string]string state -> "user" или "bot" + tokenUpdatedUser bool + tokenUpdatedBot bool + updateMutex sync.Mutex +) + +type Server struct { + assets embed.FS + platformMgr *platforms.Manager + cmdProc *commands.Processor + eventProc *events.Processor + httpSrv *http.Server + twitchAuth *platforms.TwitchAuth + notifMgr *notifications.Manager + webSrvMgr *webservices.Manager + twitchClientID string + twitchClientSecret string +} + +type UserActionRequest struct { + Username string `json:"username"` + Action string `json:"action"` // warn, timeout10sec, timeout10min, ban, unban, set_vip, unset_vip, set_mod, unset_mod, toggle_mark + Platform string `json:"platform"` +} + +func NewServer(assets embed.FS, pm *platforms.Manager, cp *commands.Processor, ep *events.Processor, nm *notifications.Manager, wsm *webservices.Manager, twitchClientID, twitchClientSecret string) *Server { + return &Server{ + assets: assets, + platformMgr: pm, + cmdProc: cp, + eventProc: ep, + notifMgr: nm, + webSrvMgr: wsm, + twitchAuth: platforms.NewTwitchAuth(twitchClientID, twitchClientSecret), + } +} + +func (s *Server) Start(addr string) error { + mux := http.NewServeMux() + + // Статика + staticFS, err := fs.Sub(s.assets, "internal/webui/static") + if err != nil { + logger.Error("Failed to create static sub FS: %v", err) + } else { + mux.Handle("/static/", http.StripPrefix("/static/", http.FileServer(http.FS(staticFS)))) + } + + // Страницы + mux.HandleFunc("/", s.indexHandler) + mux.HandleFunc("/platforms", s.platformsHandler) + mux.HandleFunc("/commands", s.commandsHandler) + mux.HandleFunc("/events", s.eventsHandler) + mux.HandleFunc("/hotkeys", s.hotkeysHandler) + mux.HandleFunc("/webservices", s.webservicesHandler) + mux.HandleFunc("/logs", s.logsHandler) + + // API (старые, но нужные) + mux.HandleFunc("/api/commands", s.apiCommandsHandler) + mux.HandleFunc("/api/events", s.apiEventsHandler) + mux.HandleFunc("/api/hotkeys", s.apiHotkeysHandler) + + mux.HandleFunc("/api/platforms/twitch/auth", s.apiTwitchAuthHandler) + mux.HandleFunc("/api/platforms/twitch/status", s.apiTwitchStatusHandler) + mux.HandleFunc("/api/platforms/twitch/auth/user", s.apiTwitchAuthUserHandler) + mux.HandleFunc("/api/platforms/twitch/auth/bot", s.apiTwitchAuthBotHandler) + mux.HandleFunc("/api/platforms/twitch/auth/callback", s.apiTwitchAuthCallbackHandler) + mux.HandleFunc("/api/platforms/twitch/token_check", s.apiTwitchTokenCheckHandler) + mux.HandleFunc("/api/platforms/twitch/token_expiry", s.apiTwitchTokenExpiryHandler) + + mux.HandleFunc("/api/users", s.apiUsersHandler) + mux.HandleFunc("/api/users/action", s.apiUserActionHandler) + + mux.HandleFunc("/ai", s.aiPageHandler) + mux.HandleFunc("/api/ai/config", s.apiAIConfigHandler) + mux.HandleFunc("/api/ai/test", s.apiAITestHandler) + + mux.HandleFunc("/api/platforms/twitch/eventsub/status", s.apiTwitchEventSubStatusHandler) + mux.HandleFunc("/api/webservice/url", s.apiWebserviceURLHandler) + + mux.HandleFunc("/api/commands/test", s.apiCommandsTestHandler) + + mux.HandleFunc("/api/logs/stream", s.apiLogsStreamHandler) + + mux.HandleFunc("/notifications", s.notificationsHandler) + mux.HandleFunc("/api/notifications", s.apiNotificationsHandler) + mux.HandleFunc("/api/notifications/test", s.apiNotificationTestHandler) + + mux.HandleFunc("/api/sounds", s.apiSoundsHandler) + mux.HandleFunc("/api/sounds/upload", s.apiSoundsUploadHandler) + mux.HandleFunc("/api/obs/notify", s.apiObsNotifyHandler) + + mux.HandleFunc("/api/webservices/media", s.apiWebServiceMediaHandler) + + // НОВЫЕ API для управления множественными сервисами + mux.HandleFunc("/api/webservices", s.apiWebServicesList) // GET + mux.HandleFunc("/api/webservices/create", s.apiWebServicesCreate) // POST + mux.HandleFunc("/api/webservices/update", s.apiWebServicesUpdateConfig) // PUT (id в query) + mux.HandleFunc("/api/webservices/start", s.apiWebServicesStart) // POST?id= + mux.HandleFunc("/api/webservices/stop", s.apiWebServicesStop) // POST?id= + mux.HandleFunc("/api/webservices/delete", s.apiWebServicesDelete) // DELETE?id= + mux.HandleFunc("/api/webservices/test/chat", s.apiWebServicesTestChat) // POST?id= + mux.HandleFunc("/api/webservices/test/alert", s.apiWebServicesTestAlert) // POST?id=&event= + + mux.HandleFunc("/api/settings/duplicate_sounds", s.apiDuplicateSoundsHandler) + mux.HandleFunc("/api/sounds/list", s.apiSoundsListHandler) + mux.HandleFunc("/api/images/list", s.apiImagesListHandler) + + mux.HandleFunc("/api/webservices/alert/list", s.apiWebServicesAlertList) + + mux.HandleFunc("/api/settings/hotkey", s.apiHotkeySettingHandler) + + mux.HandleFunc("/api/logs/download", s.apiLogsDownloadHandler) + handler := corsMiddleware(mux) + s.httpSrv = &http.Server{Addr: addr, Handler: handler} + logger.Info("Web UI starting on http://%s", addr) + return s.httpSrv.ListenAndServe() +} + +func (s *Server) Stop() error { + if s.httpSrv != nil { + return s.httpSrv.Close() + } + return nil +} + +// --- Страницы (без изменений) --- +func (s *Server) indexHandler(w http.ResponseWriter, _ *http.Request) { + t, err := template.ParseFS(s.assets, "internal/webui/templates/base.html", "internal/webui/templates/dashboard.html") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = t.Execute(w, nil) +} + +func (s *Server) platformsHandler(w http.ResponseWriter, _ *http.Request) { + t := template.Must(template.ParseFS(s.assets, "internal/webui/templates/base.html", "internal/webui/templates/platforms.html")) + _ = t.Execute(w, nil) +} + +func (s *Server) commandsHandler(w http.ResponseWriter, _ *http.Request) { + t := template.Must(template.ParseFS(s.assets, "internal/webui/templates/base.html", "internal/webui/templates/commands.html")) + _ = t.Execute(w, nil) +} + +func (s *Server) eventsHandler(w http.ResponseWriter, _ *http.Request) { + t, err := template.ParseFS(s.assets, "internal/webui/templates/base.html", "internal/webui/templates/events.html") + if err != nil { + logger.Error("Failed to parse events template: %v", err) + http.Error(w, "Internal Server Error", http.StatusInternalServerError) + return + } + _ = t.Execute(w, nil) +} + +func (s *Server) hotkeysHandler(w http.ResponseWriter, _ *http.Request) { + t := template.Must(template.ParseFS(s.assets, "internal/webui/templates/base.html", "internal/webui/templates/hotkeys.html")) + _ = t.Execute(w, nil) +} + +func (s *Server) webservicesHandler(w http.ResponseWriter, _ *http.Request) { + // Здесь должен быть новый шаблон для управления несколькими сервисами + t := template.Must(template.ParseFS(s.assets, "internal/webui/templates/base.html", "internal/webui/templates/webservices.html")) + _ = t.Execute(w, nil) +} + +func (s *Server) logsHandler(w http.ResponseWriter, _ *http.Request) { + t := template.Must(template.ParseFS(s.assets, "internal/webui/templates/base.html", "internal/webui/templates/logs.html")) + _ = t.Execute(w, nil) +} + +// ApiEventsHandler обрабатывает GET (получение действий) и POST (сохранение) +func (s *Server) apiEventsHandler(w http.ResponseWriter, r *http.Request) { + platform := r.URL.Query().Get("platform") + eventName := r.URL.Query().Get("event") + if platform == "" || eventName == "" { + http.Error(w, "missing platform or event", http.StatusBadRequest) + return + } + + switch r.Method { + case http.MethodGet: + actions, err := db.GetEventActions(platform, eventName) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(actions) + + case http.MethodPost: + var actions []db.Action + if err := json.NewDecoder(r.Body).Decode(&actions); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := db.SaveEventActions(platform, eventName, actions); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +func (s *Server) apiHotkeysHandler(w http.ResponseWriter, r *http.Request) { + platform := r.URL.Query().Get("platform") + if r.Method == http.MethodGet { + rules, _ := db.GetHotkeyRules(platform) + _ = json.NewEncoder(w).Encode(rules) + } else if r.Method == http.MethodPost { + var req struct { + MinAmount int `json:"min_amount"` + Comb string `json:"combination"` + } + _ = json.NewDecoder(r.Body).Decode(&req) + _ = db.AddHotkeyRule(platform, req.MinAmount, req.Comb) + } else if r.Method == http.MethodDelete { + minAmt, _ := strconv.Atoi(r.URL.Query().Get("min_amount")) + _ = db.DeleteHotkeyRule(platform, minAmt) + } +} + +func (s *Server) apiTwitchAuthHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + tokens, err := db.GetPlatformTokens("twitch") + if err != nil { + http.Error(w, err.Error(), 500) + return + } + username := "" + hasToken := false + maskedToken := "" + if tokens != nil { + username = tokens.UserLogin + if tokens.BotToken != "" { + hasToken = true + if len(tokens.BotToken) > 8 { + maskedToken = tokens.BotToken[:4] + "****" + tokens.BotToken[len(tokens.BotToken)-4:] + } + } + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "username": username, + "hasToken": hasToken, + "maskedToken": maskedToken, + }) + return + } + if r.Method == http.MethodPost { + token := r.FormValue("token") + username := r.FormValue("username") + if token == "" || username == "" { + http.Error(w, "token and username required", 400) + return + } + tokens, _ := db.GetPlatformTokens("twitch") + if tokens == nil { + tokens = &db.PlatformTokens{} + } + tokens.BotToken = token + tokens.BotLogin = username + if err := db.SetPlatformTokens("twitch", tokens); err != nil { + http.Error(w, err.Error(), 500) + return + } + if p := s.platformMgr.GetPlatform("twitch"); p != nil { + p.Disconnect() + _ = p.Connect() + } + _, _ = w.Write([]byte("OK")) + return + } + http.Error(w, "method not allowed", 405) +} + +func (s *Server) apiTwitchStatusHandler(w http.ResponseWriter, _ *http.Request) { + connected := s.platformMgr.IsConnected("twitch") + _ = json.NewEncoder(w).Encode(map[string]bool{"connected": connected}) +} + +func (s *Server) apiCommandsTestHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + Template string `json:"template"` + Username string `json:"username"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if err := parser.ValidateTemplate(req.Template); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + result, soundFiles, _, err := parser.ParseTemplate(req.Template, req.Username, "", "[AI заглушка]", func() string { + return "TestUser" + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "result": result, + "soundFiles": soundFiles, + }) +} + +func (s *Server) apiCommandsHandler(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodGet: + cmds, err := db.GetCommands() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + _ = json.NewEncoder(w).Encode(cmds) + case http.MethodPost: + var cmd db.Command + if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil { + http.Error(w, err.Error(), 400) + return + } + if err := parser.ValidateTemplate(cmd.Template); err != nil { + http.Error(w, "Invalid template: "+err.Error(), 400) + return + } + err := db.AddCommand(cmd.Trigger, cmd.Template, cmd.Enabled, cmd.CooldownSec, cmd.Permission) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(http.StatusCreated) + case http.MethodPut: + var cmd db.Command + if err := json.NewDecoder(r.Body).Decode(&cmd); err != nil { + http.Error(w, err.Error(), 400) + return + } + if err := parser.ValidateTemplate(cmd.Template); err != nil { + http.Error(w, "Invalid template: "+err.Error(), 400) + return + } + err := db.UpdateCommand(cmd.ID, cmd.Trigger, cmd.Template, cmd.Enabled, cmd.CooldownSec, cmd.Permission) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + case http.MethodDelete: + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "invalid id", 400) + return + } + if err := db.DeleteCommand(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + default: + http.Error(w, "method not allowed", 405) + } +} + +func (s *Server) apiLogsStreamHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("Access-Control-Allow-Origin", "*") + + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "Streaming unsupported", http.StatusInternalServerError) + return + } + + recent := logger.GetRecent(50) + for _, entry := range recent { + data, _ := json.Marshal(entry) + _, _ = fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + } + + ch := logger.Subscribe() + defer logger.Unsubscribe(ch) + + for { + select { + case entry := <-ch: + data, _ := json.Marshal(entry) + _, _ = fmt.Fprintf(w, "data: %s\n\n", data) + flusher.Flush() + case <-r.Context().Done(): + return + } + } +} + +func (s *Server) apiTwitchAuthUserHandler(w http.ResponseWriter, _ *http.Request) { + scopes := []string{ + "channel:manage:vips", + "moderator:read:followers", + "channel:manage:moderators", + "channel:manage:redemptions", + "channel:manage:broadcast", + "channel:read:subscriptions", + "channel:read:redemptions", + "user:read:email", + } + state := generateState() + authStates.Store(state, "user") + url := s.twitchAuth.GenerateAuthURL(scopes, state) + _ = s.twitchAuth.StartTempServer() + _ = json.NewEncoder(w).Encode(map[string]string{"url": url}) +} + +func (s *Server) apiTwitchAuthBotHandler(w http.ResponseWriter, _ *http.Request) { + scopes := []string{ + "moderator:manage:shoutouts", + "moderator:manage:announcements", + "moderator:manage:banned_users", + "moderator:manage:warnings", + "moderator:read:followers", + "channel:manage:raids", + "channel:manage:moderators", + "channel:read:redemptions", + "chat:read", + "chat:edit", + "user:read:emotes", + } + state := generateState() + authStates.Store(state, "bot") + url := s.twitchAuth.GenerateAuthURL(scopes, state) + "&force_verify=true" + _ = s.twitchAuth.StartTempServer() + _ = json.NewEncoder(w).Encode(map[string]string{"url": url}) +} + +func (s *Server) apiTwitchAuthCallbackHandler(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("token") + state := r.URL.Query().Get("state") + if token == "" || state == "" { + http.Error(w, "missing token or state", 400) + return + } + + val, ok := authStates.Load(state) + if !ok { + http.Error(w, "invalid state", 400) + return + } + authType := val.(string) + authStates.Delete(state) + + username, err := getUserFromToken(token) + if err != nil { + logger.Error("Failed to get username from token: %v", err) + http.Error(w, "failed to get user info", 500) + return + } + + tokens, _ := db.GetPlatformTokens("twitch") + if tokens == nil { + tokens = &db.PlatformTokens{} + } + if authType == "user" { + tokens.UserToken = token + tokens.UserLogin = username + } else { + tokens.BotToken = token + tokens.BotLogin = username + } + if err := db.SetPlatformTokens("twitch", tokens); err != nil { + logger.Error("Failed to save tokens: %v", err) + http.Error(w, "db error", 500) + return + } + + if p := s.platformMgr.GetPlatform("twitch"); p != nil { + p.Disconnect() + _ = p.Connect() + } + + updateMutex.Lock() + if authType == "user" { + tokenUpdatedUser = true + } else { + tokenUpdatedBot = true + } + updateMutex.Unlock() + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"status":"ok"}`)) +} + +func generateState() string { + b := make([]byte, 16) + _, _ = rand.Read(b) + return base64.URLEncoding.EncodeToString(b) +} + +func getUserFromToken(token string) (string, error) { + req, err := http.NewRequest("GET", "https://id.twitch.tv/oauth2/validate", nil) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return "", err + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + if resp.StatusCode != 200 { + return "", fmt.Errorf("token invalid, status %d", resp.StatusCode) + } + var result struct { + Login string `json:"login"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return "", err + } + return result.Login, nil +} + +func (s *Server) apiTwitchTokenCheckHandler(w http.ResponseWriter, r *http.Request) { + authType := r.URL.Query().Get("type") + updated := false + if authType == "user" && tokenUpdatedUser { + updated = true + tokenUpdatedUser = false + } else if authType == "bot" && tokenUpdatedBot { + updated = true + tokenUpdatedBot = false + } + _ = json.NewEncoder(w).Encode(map[string]bool{"updated": updated}) +} + +func (s *Server) apiTwitchTokenExpiryHandler(w http.ResponseWriter, _ *http.Request) { + tokens, err := db.GetPlatformTokens("twitch") + if err != nil || tokens == nil { + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "user_expiry_days": nil, + "bot_expiry_days": nil, + }) + return + } + + userExpiry := getTokenExpiryDays(tokens.UserToken) + botExpiry := getTokenExpiryDays(tokens.BotToken) + + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "user_expiry_days": userExpiry, + "bot_expiry_days": botExpiry, + }) +} + +func getTokenExpiryDays(token string) interface{} { + if token == "" { + return nil + } + req, err := http.NewRequest("GET", "https://id.twitch.tv/oauth2/validate", nil) + if err != nil { + return nil + } + req.Header.Set("Authorization", "Bearer "+token) + client := &http.Client{Timeout: 10 * time.Second} + resp, err := client.Do(req) + if err != nil { + return nil + } + defer func(Body io.ReadCloser) { + _ = Body.Close() + }(resp.Body) + if resp.StatusCode != 200 { + return nil + } + var result struct { + ExpiresIn int `json:"expires_in"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + return nil + } + days := result.ExpiresIn / 86400 + if days < 0 { + days = 0 + } + return days +} + +func corsMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Access-Control-Allow-Origin", "*") + w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") + w.Header().Set("Access-Control-Allow-Headers", "Content-Type") + if r.Method == http.MethodOptions { + w.WriteHeader(http.StatusOK) + return + } + next.ServeHTTP(w, r) + }) +} + +func (s *Server) apiUsersHandler(w http.ResponseWriter, _ *http.Request) { + users := s.platformMgr.GetAllUsers() + _ = json.NewEncoder(w).Encode(users) +} + +func (s *Server) apiUserActionHandler(w http.ResponseWriter, r *http.Request) { + var req UserActionRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + platform := s.platformMgr.GetPlatform(req.Platform) + if platform == nil { + http.Error(w, "platform not found", http.StatusNotFound) + return + } + tw, ok := platform.(*platforms.TwitchPlatform) + if !ok { + http.Error(w, "only twitch supported", http.StatusBadRequest) + return + } + switch req.Action { + case "warn": + _ = tw.SendMessage("/warn " + req.Username) + case "timeout10sec": + if err := tw.TimeoutUserViaAPI(req.Username, 10); err != nil { + logger.Error("Timeout error: %v", err) + } + case "timeout10min": + if err := tw.TimeoutUserViaAPI(req.Username, 600); err != nil { + logger.Error("Timeout error: %v", err) + } + case "ban": + if err := tw.BanUserViaAPI(req.Username); err != nil { + logger.Error("Ban error: %v", err) + } + case "unban": + if err := tw.UnbanUserViaAPI(req.Username); err != nil { + logger.Error("Unban error: %v", err) + } + case "set_vip": + if err := tw.AddVipViaAPI(req.Username); err != nil { + logger.Error("Add VIP error: %v", err) + } else { + s.platformMgr.SetVip(req.Username, true) + } + case "unset_vip": + if err := tw.RemoveVipViaAPI(req.Username); err != nil { + logger.Error("Remove VIP error: %v", err) + } else { + s.platformMgr.SetVip(req.Username, false) + } + case "set_mod": + if err := tw.AddModViaAPI(req.Username); err != nil { + logger.Error("Add mod error: %v", err) + } else { + s.platformMgr.SetMod(req.Username, true) + } + case "unset_mod": + if err := tw.RemoveModViaAPI(req.Username); err != nil { + logger.Error("Remove mod error: %v", err) + } else { + s.platformMgr.SetMod(req.Username, false) + } + case "toggle_mark": + marked, _, _ := db.IsUserMarked(req.Username, req.Platform) + newMarked := !marked + _ = db.SetUserMarked(req.Username, req.Platform, newMarked) + s.platformMgr.SetMarked(req.Username, newMarked) + default: + http.Error(w, "unknown action", http.StatusBadRequest) + return + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) aiPageHandler(w http.ResponseWriter, _ *http.Request) { + t := template.Must(template.ParseFS(s.assets, "internal/webui/templates/base.html", "internal/webui/templates/ai.html")) + _ = t.Execute(w, nil) +} + +func (s *Server) apiAIConfigHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + cfg, err := db.GetAIConfig() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + _ = json.NewEncoder(w).Encode(cfg) + return + } + if r.Method == http.MethodPost { + bodyBytes, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), 400) + return + } + + var cfg db.AIConfig + if err := json.Unmarshal(bodyBytes, &cfg); err != nil { + logger.Error("JSON decode error: %v", err) + http.Error(w, err.Error(), 400) + return + } + + if err := db.SaveAIConfig(&cfg); err != nil { + logger.Error("Save error: %v", err) + http.Error(w, err.Error(), 500) + return + } + + provider, err := ai.NewProvider(&cfg) + if err != nil { + logger.Warn("Failed to recreate AI provider: %v", err) + } else { + s.cmdProc.UpdateAIProvider(provider) + } + w.WriteHeader(http.StatusOK) + return + } + http.Error(w, "method not allowed", 405) +} + +func (s *Server) apiAITestHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + var req struct { + Prompt string `json:"prompt"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), 400) + return + } + cfg, err := db.GetAIConfig() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + provider, err := ai.NewProvider(cfg) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + answer, err := provider.Ask(context.Background(), req.Prompt) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + _ = json.NewEncoder(w).Encode(map[string]string{"answer": answer}) +} + +func (s *Server) apiTwitchEventSubStatusHandler(w http.ResponseWriter, _ *http.Request) { + connected, subscriptions, err := s.platformMgr.GetTwitchEventSubStatus() + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{ + "connected": connected, + "subscriptions": subscriptions, + }) +} + +func (s *Server) apiWebserviceURLHandler(w http.ResponseWriter, _ *http.Request) { + url := "http://localhost:8080" + _ = json.NewEncoder(w).Encode(map[string]string{"url": url}) +} + +func (s *Server) notificationsHandler(w http.ResponseWriter, _ *http.Request) { + t := template.Must(template.ParseFS(s.assets, "internal/webui/templates/base.html", "internal/webui/templates/notifications.html")) + _ = t.Execute(w, nil) +} + +func (s *Server) apiNotificationsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + settings := s.notifMgr.GetAll() + _ = json.NewEncoder(w).Encode(settings) + return + } + if r.Method == http.MethodPost { + var ns db.NotificationSetting + if err := json.NewDecoder(r.Body).Decode(&ns); err != nil { + logger.Error("Failed to decode notification setting: %v", err) + http.Error(w, err.Error(), 400) + return + } + logger.Info("Saving notification setting: %+v", ns) + if err := s.notifMgr.UpdateSetting(&ns); err != nil { + logger.Error("UpdateSetting error: %v", err) + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(http.StatusOK) + return + } + http.Error(w, "method not allowed", 405) +} + +func (s *Server) apiNotificationTestHandler(w http.ResponseWriter, r *http.Request) { + eventName := r.URL.Query().Get("event") + if eventName == "" { + http.Error(w, "missing event", 400) + return + } + _ = s.notifMgr.PlayEvent(eventName) + w.WriteHeader(http.StatusOK) +} + +func (s *Server) apiSoundsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + files, _ := filepath.Glob("data/sounds/*.mp3") + wavs, _ := filepath.Glob("data/sounds/*.wav") + files = append(files, wavs...) + names := make([]string, len(files)) + for i, f := range files { + names[i] = filepath.Base(f) + } + _ = json.NewEncoder(w).Encode(names) + return + } + if r.Method == http.MethodDelete { + filename := r.URL.Query().Get("file") + if filename == "" { + http.Error(w, "missing file", 400) + return + } + path := filepath.Join("data/sounds", filepath.Base(filename)) + if err := os.Remove(path); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(http.StatusOK) + return + } + http.Error(w, "method not allowed", 405) +} + +func (s *Server) apiSoundsUploadHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", 405) + return + } + file, header, err := r.FormFile("sound") + if err != nil { + http.Error(w, err.Error(), 400) + return + } + defer func(file multipart.File) { + _ = file.Close() + }(file) + outPath := filepath.Join("data/sounds", filepath.Base(header.Filename)) + out, err := os.Create(outPath) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + defer func(out *os.File) { + _ = out.Close() + }(out) + _, _ = io.Copy(out, file) + w.WriteHeader(http.StatusOK) +} + +func (s *Server) apiObsNotifyHandler(w http.ResponseWriter, r *http.Request) { + event := r.URL.Query().Get("event") + if event == "" { + http.Error(w, "missing event", 400) + return + } + go func() { + _ = s.notifMgr.PlayEvent(event) + }() + w.WriteHeader(http.StatusOK) +} + +// apiWebServiceMediaHandler управляет медиафайлами (изображения, звуки) +func (s *Server) apiWebServiceMediaHandler(w http.ResponseWriter, r *http.Request) { + mediaDir := "data/web" + if err := os.MkdirAll(mediaDir, 0755); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + switch r.Method { + case http.MethodGet: + files, err := os.ReadDir(mediaDir) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + var result []map[string]string + for _, f := range files { + if f.IsDir() { + continue + } + name := f.Name() + ext := strings.ToLower(filepath.Ext(name)) + typ := "unknown" + if ext == ".png" || ext == ".gif" || ext == ".jpg" || ext == ".jpeg" { + typ = "image" + } else if ext == ".mp3" || ext == ".wav" { + typ = "sound" + } + result = append(result, map[string]string{"name": name, "type": typ}) + } + _ = json.NewEncoder(w).Encode(result) + + case http.MethodPost: + file, header, err := r.FormFile("file") + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + defer func(file multipart.File) { + _ = file.Close() + }(file) + filename := filepath.Base(header.Filename) + outPath := filepath.Join(mediaDir, filename) + out, err := os.Create(outPath) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func(out *os.File) { + _ = out.Close() + }(out) + if _, err := io.Copy(out, file); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + + case http.MethodDelete: + filename := r.URL.Query().Get("file") + if filename == "" { + http.Error(w, "missing file parameter", http.StatusBadRequest) + return + } + filename = filepath.Base(filename) + path := filepath.Join(mediaDir, filename) + if err := os.Remove(path); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + + default: + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + } +} + +// --- НОВЫЕ API для множественных сервисов --- +func (s *Server) apiWebServicesList(w http.ResponseWriter, _ *http.Request) { + list, err := db.GetAllWebServices() + if err != nil { + logger.Error("GetAllWebServices error: %v", err) + http.Error(w, err.Error(), 500) + return + } + _ = json.NewEncoder(w).Encode(list) +} + +func (s *Server) apiWebServicesCreate(w http.ResponseWriter, r *http.Request) { + var req struct { + Type string `json:"type"` + Port int `json:"port"` + Config interface{} `json:"config"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), 400) + return + } + id, err := s.webSrvMgr.AddService(req.Type, req.Port, req.Config) + if err != nil { + http.Error(w, err.Error(), 500) + return + } + // Автоматически запускаем созданный сервис + if err := s.webSrvMgr.StartService(id); err != nil { + logger.Error("Failed to start service %d: %v", id, err) + // Не возвращаем ошибку, сервис хотя бы создан + } + _ = json.NewEncoder(w).Encode(map[string]int{"id": id}) +} + +func (s *Server) apiWebServicesUpdateConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPut { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + var req struct { + Type string `json:"type"` + Port int `json:"port"` + Config interface{} `json:"config"` + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), 400) + return + } + if err := db.UpdateWebService(id, req.Port, req.Config, req.Enabled); err != nil { + http.Error(w, err.Error(), 500) + return + } + // Если сервис запущен, перезапускаем его с новой конфигурацией + if srv := s.webSrvMgr.GetService(id); srv != nil && srv.IsRunning() { + _ = s.webSrvMgr.StopService(id) + _ = s.webSrvMgr.StartService(id) + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) apiWebServicesStart(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + if err := s.webSrvMgr.StartService(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) apiWebServicesStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + if err := s.webSrvMgr.StopService(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) apiWebServicesDelete(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idStr := r.URL.Query().Get("id") + id, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + if err := s.webSrvMgr.DeleteService(id); err != nil { + http.Error(w, err.Error(), 500) + return + } + w.WriteHeader(http.StatusOK) +} + +func (s *Server) apiWebServicesTestChat(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + idStr := r.URL.Query().Get("id") + _, err := strconv.Atoi(idStr) + if err != nil { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + var req struct { + Username string `json:"username"` + Message string `json:"message"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), 400) + return + } + if req.Username == "" { + req.Username = "TestUser" + } + s.webSrvMgr.SendChatMessage(webservices.ChatMessage{ + Username: req.Username, + Message: req.Message, + Timestamp: time.Now().Unix(), + }) + w.WriteHeader(http.StatusOK) +} + +func (s *Server) apiWebServicesTestAlert(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + var req struct { + ServiceID int `json:"serviceId"` + EventType string `json:"eventType"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), 400) + return + } + // Проверяем, существует ли сервис + srv := s.webSrvMgr.GetService(req.ServiceID) + if srv == nil || srv.GetType() != "alert" { + http.Error(w, "alert service not found", 404) + return + } + // Отправляем тестовое событие + data := map[string]interface{}{ + "username": "TestUser", + "tier": "1000", + "viewers": 42, + "gifter": "Gifter", + "cumulative_total": 5, + "reward_title": "Test Reward", + } + s.webSrvMgr.SendAlertEvent(webservices.AlertEvent{Type: req.EventType, Data: data}) + w.WriteHeader(http.StatusOK) +} + +// apiDuplicateSoundsHandler управляет настройкой дублирования звуков из команд +func (s *Server) apiDuplicateSoundsHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + val, _ := db.GetSetting("duplicate_command_sounds") + enabled := val == "true" + _ = json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled}) + return + } + if r.Method == http.MethodPost { + var req struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + val := "false" + if req.Enabled { + val = "true" + } + if err := db.SetSetting("duplicate_command_sounds", val); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + return + } + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) +} + +// apiSoundsListHandler возвращает список звуковых файлов из data/sounds +func (s *Server) apiSoundsListHandler(w http.ResponseWriter, _ *http.Request) { + files, err := filepath.Glob("data/sounds/*.mp3") + if err != nil { + http.Error(w, err.Error(), 500) + return + } + wavs, _ := filepath.Glob("data/sounds/*.wav") + files = append(files, wavs...) + names := make([]string, len(files)) + for i, f := range files { + names[i] = filepath.Base(f) + } + _ = json.NewEncoder(w).Encode(names) +} + +// apiImagesListHandler возвращает список изображений из data/web +func (s *Server) apiImagesListHandler(w http.ResponseWriter, _ *http.Request) { + files, err := filepath.Glob("data/web/*.png") + if err != nil { + http.Error(w, err.Error(), 500) + return + } + jpgs, _ := filepath.Glob("data/web/*.jpg") + files = append(files, jpgs...) + jpegs, _ := filepath.Glob("data/web/*.jpeg") + files = append(files, jpegs...) + gifs, _ := filepath.Glob("data/web/*.gif") + files = append(files, gifs...) + names := make([]string, len(files)) + for i, f := range files { + names[i] = filepath.Base(f) + } + _ = json.NewEncoder(w).Encode(names) +} + +// apiWebServicesAlertList возвращает список ID и портов alert-сервисов для выпадающего списка +func (s *Server) apiWebServicesAlertList(w http.ResponseWriter, r *http.Request) { + services, err := db.GetAllWebServices() + if err != nil { + http.Error(w, err.Error(), 500) + return + } + type AlertInfo struct { + ID int `json:"id"` + Port int `json:"port"` + Name string `json:"name"` + } + var result []AlertInfo + for _, svc := range services { + if svc.Type == "alert" { + result = append(result, AlertInfo{ + ID: svc.ID, + Port: svc.Port, + Name: fmt.Sprintf("Alert на порту %d", svc.Port), + }) + } + } + json.NewEncoder(w).Encode(result) +} + +func (s *Server) apiHotkeySettingHandler(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + val, _ := db.GetSetting("hotkey_enabled") + enabled := val == "true" + json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled}) + return + } + if r.Method == http.MethodPost { + var req struct { + Enabled bool `json:"enabled"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), 400) + return + } + val := "false" + if req.Enabled { + val = "true" + } + if err := db.SetSetting("hotkey_enabled", val); err != nil { + http.Error(w, err.Error(), 500) + return + } + // Применяем настройку к пакету hotkey + hotkey.SetEnabled(req.Enabled) + w.WriteHeader(http.StatusOK) + return + } + http.Error(w, "method not allowed", 405) +} + +func (s *Server) apiLogsDownloadHandler(w http.ResponseWriter, r *http.Request) { + entries := logger.GetAll() + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + w.Header().Set("Content-Disposition", "attachment; filename=\"bot_logs.txt\"") + for _, e := range entries { + line := fmt.Sprintf("[%s] [%s] %s\n", e.Time.Format("2006-01-02 15:04:05"), e.Level, e.Message) + _, _ = w.Write([]byte(line)) + } +} diff --git a/internal/webui/static/styles.css b/internal/webui/static/styles.css new file mode 100644 index 0000000..a12d41a --- /dev/null +++ b/internal/webui/static/styles.css @@ -0,0 +1,414 @@ +/* ---------- БАЗОВЫЕ СТИЛИ И ТЕМЫ ---------- */ +body { + font-family: Arial, sans-serif; + margin: 0; + padding: 0; + background: #f4f4f4; + color: #333; + transition: background 0.2s, color 0.2s; +} + +nav { + background: #333; + color: white; + padding: 10px 20px; + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 10px; +} +nav a { + color: white; + text-decoration: none; + margin-right: 20px; +} +nav a:hover { + text-decoration: underline; +} +.container { + padding: 20px; +} +.card { + background: white; + border-radius: 8px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); + padding: 20px; + margin-bottom: 20px; +} +button, input[type="submit"] { + background: #007bff; + color: white; + border: none; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; +} +button:hover { + background: #0056b3; +} +input[type="text"], input[type="password"], textarea, select { + width: 100%; + padding: 8px; + margin: 8px 0; + border: 1px solid #ccc; + border-radius: 4px; + background: #fff; + color: #333; + box-sizing: border-box; +} +table { + width: 100%; + border-collapse: collapse; +} +th, td { + border: 1px solid #ddd; + padding: 8px; + text-align: left; +} +th { + background-color: #f2f2f2; +} +.status.online { + color: green; +} +.status.offline { + color: red; +} +.theme-switch { + cursor: pointer; + background: #555; + padding: 5px 10px; + border-radius: 20px; + font-size: 0.9em; + white-space: nowrap; +} +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.7); + z-index: 1000; + align-items: center; + justify-content: center; +} +.modal-content { + background: white; + border-radius: 8px; + padding: 20px; + max-width: 600px; + width: 90%; +} +.modal-content pre { + background: #f0f0f0; + padding: 10px; + border-radius: 4px; + white-space: pre-wrap; +} +.provider-fields { + margin-top: 10px; +} +.provider-fields label { + display: block; + margin-top: 8px; +} +.test-result-block { + background: #f0f0f0; + color: #333; + border: 1px solid #ccc; + margin-top: 10px; + padding: 10px; + border-radius: 4px; +} +.action-item { + background: #f9f9f9; + margin-bottom: 10px; + padding: 10px; + border-radius: 4px; + border: 1px solid #ccc; +} +.action-fields { + margin-top: 10px; + margin-bottom: 10px; +} +.action-fields label { + display: block; + margin-top: 5px; +} +button.remove-action { + background: #d9534f; + margin-top: 5px; +} +#log-container { + background: #1e1e1e; + color: #d4d4d4; + font-family: monospace; + padding: 10px; + height: 70vh; + overflow-y: auto; + border-radius: 5px; +} +#notification { + position: fixed; + top: 20px; + right: 20px; + z-index: 1000; + background: #333; + color: white; + padding: 10px 20px; + border-radius: 5px; + display: none; +} + +/* ---------- ТЁМНАЯ ТЕМА ---------- */ +body.dark { + background: #1e1e1e; + color: #ddd; +} +body.dark nav { + background: #1e1e1e; +} +body.dark nav a { + color: #ddd; +} +body.dark nav a:hover { + color: white; +} +body.dark .card { + background: #2d2d2d; + box-shadow: 0 2px 4px rgba(0,0,0,0.5); +} +body.dark input, body.dark textarea, body.dark select { + background: #3c3c3c; + border-color: #555; + color: #eee; +} +body.dark table th { + background-color: #3c3c3c; + color: #ddd; +} +body.dark table td { + border-color: #444; + color: #ddd; +} +body.dark button { + background: #0d6efd; +} +body.dark .modal-content { + background: #2d2d2d; + color: #ddd; +} +body.dark .modal-content pre { + background: #1e1e1e; + color: #ddd; + border: 1px solid #555; +} +body.dark .test-result-block { + background: #1e1e1e; + color: #ddd; + border-color: #555; +} +body.dark .theme-switch { + background: #444; + color: #ddd; +} +body.dark table thead th, +body.dark table th { + background-color: #3c3c3c !important; + color: #eee !important; + border-color: #555 !important; +} +body.dark table tbody td { + background-color: #2d2d2d; + color: #ddd; + border-color: #555; +} +body.dark table { + background-color: #2d2d2d; +} +body.dark .action-item { + background: #2d2d2d; + border-color: #555; +} +body.dark #log-container { + background: #0a0a0a; + color: #d4d4d4; +} +body.dark #notification { + background: #444; +} + +/* ---------- АДАПТИВНОСТЬ ---------- */ +@media (max-width: 768px) { + nav { + flex-direction: column; + align-items: stretch; + } + nav div:first-child { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 10px; + } + nav a { + margin-right: 0; + padding: 5px 10px; + } + .container { + padding: 10px; + } + .card { + padding: 15px; + } + button, input[type="submit"] { + width: 100%; + margin-top: 5px; + padding: 10px; + } + table { + display: block; + overflow-x: auto; + white-space: nowrap; + } + th, td { + white-space: normal; + word-break: break-word; + } + .modal-content { + width: 95%; + padding: 15px; + } + .action-item button { + width: auto; + margin-right: 5px; + } + #notification { + left: 20px; + right: 20px; + text-align: center; + } + input, textarea, select { + font-size: 16px; + } + #users-table button { + margin: 2px; + padding: 4px 8px; + font-size: 0.75rem; + } + img { + max-width: 100%; + height: auto; + } +} + +@media (max-width: 480px) { + nav div:first-child a { + font-size: 0.9rem; + padding: 4px 6px; + } + .card h2, .card h3 { + font-size: 1.2rem; + } + button { + font-size: 0.9rem; + } +} + +/* Модальное окно */ +.modal { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0,0,0,0.7); + z-index: 1000; + align-items: center; + justify-content: center; +} +.modal-content { + background: white; + border-radius: 8px; + padding: 20px; + max-width: 800px; + width: 90%; + max-height: 85vh; + overflow-y: auto; + box-shadow: 0 5px 15px rgba(0,0,0,0.3); +} +body.dark .modal-content { + background: #2d2d2d; + color: #ddd; +} +.modal-content h3, .modal-content h4 { + margin-top: 0; +} +.modal-content label { + display: block; + margin-top: 10px; + font-weight: bold; +} +.modal-content input[type="text"], +.modal-content input[type="number"], +.modal-content select, +.modal-content textarea { + width: 100%; + padding: 6px; + margin-top: 4px; + box-sizing: border-box; +} +.modal-content button { + margin-top: 10px; +} +.preview-box { + background: #f5f5f5; + border: 1px solid #ccc; + border-radius: 6px; + padding: 12px; + margin-top: 15px; + transition: all 0.2s; +} +body.dark .preview-box { + background: #1e1e1e; + border-color: #555; +} +.chat-preview { + font-family: Arial, sans-serif; +} +.alert-preview { + background: rgba(0,0,0,0.7); + color: white; + border-radius: 8px; + padding: 12px; + display: flex; + align-items: center; + gap: 12px; +} +.alert-preview img { + width: 48px; + height: 48px; + border-radius: 50%; + object-fit: cover; +} +.alert-preview .content { + flex: 1; +} +.alert-preview .title { + font-weight: bold; + font-size: 1.2em; +} +.action-item { + background: #f9f9f9; + margin-bottom: 10px; + padding: 10px; + border-radius: 4px; + border: 1px solid #ccc; +} +body.dark .action-item { + background: #2d2d2d; + border-color: #555; +} \ No newline at end of file diff --git a/internal/webui/templates/ai.html b/internal/webui/templates/ai.html new file mode 100644 index 0000000..2ef8c51 --- /dev/null +++ b/internal/webui/templates/ai.html @@ -0,0 +1,159 @@ +{{define "content"}} +

Настройки нейросетей

+
+
+ + + +
+ + + + +
+ + + + + + + +
+
+ +
+

Тестирование

+ + +
+
+ + +{{end}} \ No newline at end of file diff --git a/internal/webui/templates/base.html b/internal/webui/templates/base.html new file mode 100644 index 0000000..db3c27c --- /dev/null +++ b/internal/webui/templates/base.html @@ -0,0 +1,45 @@ + + + + + + TTW_Bot + + + + +
+ {{block "content" .}}{{end}} +
+
+ TTW_Bot версия 11.0.43 +
+ + + \ No newline at end of file diff --git a/internal/webui/templates/commands.html b/internal/webui/templates/commands.html new file mode 100644 index 0000000..223c1e2 --- /dev/null +++ b/internal/webui/templates/commands.html @@ -0,0 +1,434 @@ +{{define "content"}} +

Команды чата

+ +
+

Добавить / редактировать команду

+
+ + + + + + + + +
+ + + + + + + + +
+ +
+ +
+ + + + + + + + +
+
+ +
+

Список команд

+ + + + + + + +
ТриггерШаблонКулдаунПраваСтатусДействия
Загрузка...
+
+ + + + + + + + + + + + + + + + + +{{end}} \ No newline at end of file diff --git a/internal/webui/templates/dashboard.html b/internal/webui/templates/dashboard.html new file mode 100644 index 0000000..47778d5 --- /dev/null +++ b/internal/webui/templates/dashboard.html @@ -0,0 +1,142 @@ +{{define "content"}} +

Пользователи чата

+
+ + + + + + + + + + + + + + +
ПользовательСообщенийПоследняя активностьМодераторVIPПодписчикДействияОтмечать
+
+ + +{{end}} \ No newline at end of file diff --git a/internal/webui/templates/events.html b/internal/webui/templates/events.html new file mode 100644 index 0000000..980cfd0 --- /dev/null +++ b/internal/webui/templates/events.html @@ -0,0 +1,296 @@ +{{define "content"}} +

События Twitch

+

Настройте цепочку действий, которые будут выполняться при наступлении события (подписка, рейд, награда и т.д.)

+ +
+ + + +
+ + + + + + + +{{end}} \ No newline at end of file diff --git a/internal/webui/templates/hotkeys.html b/internal/webui/templates/hotkeys.html new file mode 100644 index 0000000..2d7cb62 --- /dev/null +++ b/internal/webui/templates/hotkeys.html @@ -0,0 +1,28 @@ +{{define "content"}} +

Горячие клавиши по донатам

+

Правила эмуляции нажатий клавиш при донатах.

+ +
+

Настройка эмуляции горячих клавиш

+ +

При включении бот сможет имитировать нажатия клавиш (например, для управления OBS через донаты).

+
+ + +{{end}} \ No newline at end of file diff --git a/internal/webui/templates/logs.html b/internal/webui/templates/logs.html new file mode 100644 index 0000000..4c9853a --- /dev/null +++ b/internal/webui/templates/logs.html @@ -0,0 +1,60 @@ +{{define "content"}} +

Логи бота в реальном времени

+
+ +
+
+
Подключение к потоку логов...
+
+ +{{end}} \ No newline at end of file diff --git a/internal/webui/templates/notifications.html b/internal/webui/templates/notifications.html new file mode 100644 index 0000000..1c4f20e --- /dev/null +++ b/internal/webui/templates/notifications.html @@ -0,0 +1,174 @@ +{{define "content"}} +

Звуковые уведомления

+
+

События чата и Twitch

+ + + + + +
СобытиеЗвуковой файлГромкостьВкл.Действия
+ +
+ +
+

Управление звуковыми файлами

+ + +
    +
    +
    +

    Общие настройки звуков

    + +

    При включении, звуки, отправляемые в веб-сервисы оповещений, также будут проигрываться на компьютере стримера.

    +
    + +{{end}} \ No newline at end of file diff --git a/internal/webui/templates/platforms.html b/internal/webui/templates/platforms.html new file mode 100644 index 0000000..ac5d48e --- /dev/null +++ b/internal/webui/templates/platforms.html @@ -0,0 +1,176 @@ +{{define "content"}} +
    +

    Twitch

    +
    +

    Статус бота: Загрузка...

    +

    Имя канала:

    +
    +
    + + +
    +
    +
    +

    🕒 Срок действия токенов:

    +

    👤 Стример:

    +

    🤖 Бот:

    +
    + + + +
    + + +{{end}} \ No newline at end of file diff --git a/internal/webui/templates/webservices.html b/internal/webui/templates/webservices.html new file mode 100644 index 0000000..9d96a15 --- /dev/null +++ b/internal/webui/templates/webservices.html @@ -0,0 +1,296 @@ +{{define "content"}} +

    Веб-сервисы для OBS

    +

    Создавайте несколько независимых оверлеев чата и оповещений. Каждый сервис работает на своём порту.

    + +
    + + +
    + +
    +

    Тестирование оповещений

    + + + + + +
    + +
    +

    Список сервисов

    + + + + + + + +
    IDТипПортСтатусДействия
    Загрузка...
    +
    + + + + + +{{end}} \ No newline at end of file diff --git a/main.go b/main.go new file mode 100644 index 0000000..20b9558 --- /dev/null +++ b/main.go @@ -0,0 +1,142 @@ +package main + +import ( + "embed" + "errors" + "log" + "net/http" + "os" + "stream-bot/internal/ai" + "stream-bot/internal/audio" + "stream-bot/internal/commands" + "stream-bot/internal/db" + "stream-bot/internal/events" + "stream-bot/internal/gui" + "stream-bot/internal/hotkey" + "stream-bot/internal/logger" + "stream-bot/internal/notifications" + "stream-bot/internal/platforms" + "stream-bot/internal/twitchapi" + "stream-bot/internal/userstats" + "stream-bot/internal/webservices" + "stream-bot/internal/webui" + + "github.com/joho/godotenv" +) + +//go:embed internal/webui/templates/* internal/webui/static/* +var webAssets embed.FS + +func main() { + _ = godotenv.Load() + twitchClientID := os.Getenv("TWITCH_CLIENT_ID") + twitchClientSecret := os.Getenv("TWITCH_CLIENT_SECRET") + // Создаём папку data + if err := os.MkdirAll("data", 0755); err != nil { + log.Fatalf("Cannot create data directory: %v", err) + } + if err := os.MkdirAll("data/sounds", 0755); err != nil { + logger.Warn("Cannot create sounds directory: %v", err) + } + // Инициализация логгера + if err := logger.Init("bot.log"); err != nil { + log.Fatalf("Failed to init logger: %v", err) + } + logger.Info("Starting Stream Bot...") + + // Инициализация БД + if err := db.Init("data/bot.db"); err != nil { + logger.Fatal("DB init error: %v", err) + } + defer db.Close() + + // Инициализация эмулятора клавиш + if err := hotkey.Init(); err != nil { + logger.Warn("Hotkey emulator init: %v", err) + } + + // Инициализация аудио-плеера + if err := audio.Init(); err != nil { + logger.Warn("Audio player init: %v", err) + } + defer audio.Close() + + // Инициализация эмулятора клавиш + if err := hotkey.Init(); err != nil { + logger.Warn("Hotkey emulator init: %v", err) + } + // Читаем настройку из БД + if enabledVal, _ := db.GetSetting("hotkey_enabled"); enabledVal == "true" { + hotkey.SetEnabled(true) + } else { + hotkey.SetEnabled(false) + } + userStats := userstats.NewStore() + // AI конфиг + aiConfig, err := db.GetAIConfig() + if err != nil { + logger.Warn("Failed to load AI config: %v", err) + } + var aiProvider ai.Provider + if aiConfig != nil && aiConfig.Provider != "" { + aiProvider, err = ai.NewProvider(aiConfig) + if err != nil { + logger.Warn("Failed to create AI provider: %v", err) + } + } + + // Менеджер уведомлений + notifMgr, err := notifications.NewManager() + if err != nil { + logger.Warn("Notification manager init: %v", err) + } + webSrvMgr := webservices.NewManager() + twitchAPI := twitchapi.New(twitchClientID, twitchClientSecret) + cmdProc := commands.NewProcessor(twitchAPI, aiProvider, webSrvMgr, userStats) + + var platformMgr *platforms.Manager + + sendMessage := func(platform string, text string) error { + if platformMgr == nil { + return nil + } + if p := platformMgr.GetPlatform(platform); p != nil { + return p.SendMessage(text) + } + return nil + } + + // Создаём обработчик событий, передавая notifMgr + eventProc := events.NewProcessor(sendMessage, webSrvMgr) + + platformMgr = platforms.NewManager(cmdProc, eventProc, notifMgr, webSrvMgr, twitchClientID, twitchClientSecret) + platformMgr.ConnectAll() + _ = webSrvMgr.StartAll() + // Веб-сервер + uiServer := webui.NewServer(webAssets, platformMgr, cmdProc, eventProc, notifMgr, webSrvMgr, twitchClientID, twitchClientSecret) + + getChatStatus := func() bool { + return platformMgr.IsConnected("twitch") + } + getEventSubStatus := func() (bool, []string) { + connected, subs, _ := platformMgr.GetTwitchEventSubStatus() + return connected, subs + } + + go func() { + if err := uiServer.Start(":8080"); err != nil && !errors.Is(err, http.ErrServerClosed) { + logger.Error("UI server error: %v", err) + } + }() + + webURL := "http://localhost:8080" + gui.Run(webURL, getChatStatus, getEventSubStatus) + + logger.Info("Shutting down...") + platformMgr.StopAll() + _ = uiServer.Stop() + audio.Close() + db.Close() + logger.Info("Bot stopped") + os.Exit(0) +} diff --git a/rsrc.syso b/rsrc.syso new file mode 100644 index 0000000000000000000000000000000000000000..9a6878bafec60e4304570d8cc22d080de334c9dd GIT binary patch literal 93936 zcmeFa2Y^)Nx&N=msL4%Ydj2;z_vZd@dafE35v7WNidYg8ORgy<#%t85)MYD66FZ_N zb}_Lf7HlzM6cs^wXX$0xU0Al4>1XE5%=7zv-!lU%7YK`B2^@I$%$%7y<$b^J)1T-4 z&4fb_(YEfoLk|gF9e(I>Lx&B$+#L=6jOMeRyqv~f+sA09x&L~X-#+G!fA^3>Mt_Dw zm$3i;*x$rH|BYaOX#V&A=lCZMKje^C-~DCY9eT)N{2jcL^XU6?ck+GRmHS7_1IAf_88_@Ot2A9{E#89C5_=*t0w0m|Pcp0h|or z{c-GjaqJ}aW5LUt>|Z?c$Rlq)>Zqfx`1r>^eh2%ppZw$}uVG*IiBEjucYNNH&mZD9 zU*q#Da_2j^J8*gc>+~W2P80nRz77Es{lUbygqOn(JA4_h2ZN<4haYxW1)q%qXBQmC zYd(MC@WVg;7O(60d;!NFrcLHM&+9Ztee#pPIpV0J9_96C4?p6FvX38m$L|9h(4-^E*+0j3eocFvHUpY8 zmd~$-E{!_=_~Rew+O_Lz-}=_K#{bK|{L9ZdPZ6!0Hih#%O#2nTFFf+_!|(X`VIRBe z6Gt5J*ilCu@#sf5sSrQ z77B&j`>k8I+Qy9=?e*7Ruf6TI+uj^BXwa?vzJPPy#5ue~k7p$8MwDOV4y;f9Jpb+rK?_ z_0?BDvUKUvNJ~pgCK`)a%1>G*ld)_zYsqBNQmK^rzR&SspH8RUdws{}b&}8Z9bv9w z#fnHtNy+_v`}SS&sZV|CL9TfV*eU>9!gwL=MXvi4`2HRD@O|314%kuph&?#t)&(pT zFm|_b>w_56yBW`^jPav(jrz^0%*k>QkSY1U-D3`xY!X_PqNSlxzIk9O4t8Q`FqSsUvd_5J!W=DYU@mKM&Jm%7`BD@-#&{hEW7SnP z>7v5ocfR=fFFwV6jN!iSp`8b=o~B9G|4*9i83(ryaSwnW&HsVye*{15%j@HjwMX1= zD(TA1Qt@f7k?8y5`FT)j7Ljk47#QF`cAO-{VeCof=^mf z*c8S@--J=#r+iC;-6ZsD)27PI8E2eP1h&S}9spavq5TNDdMo#O0r&ZDH0h5Aw+~?t zKm&fmxc!z}lWl4Udfc#ziWQlr))tFoaL$%D>_|3R+|TnPidnU^vPrJzk<#Dk{^ z6Is5a--w3!S>D4#Zat81>B~I4=Dh4wvX%ez^2;xOLh^JJ zz8CjAi$S~Cm4sF$028R;HMoGEMZiJ`{6s8av1}9^h2e`We4_yz)$zW;d~32) zwi$ZV&OU0O#oA~gaGHSDc|N%0JW;S3LDr9_B)f5~Xxw7qh(*yY=gynghV9}Ca3neY z*U+t>gD2?|y8nYVE;`|Z+!&8@x&C4KdtlBV!&sd~>pg7PuvcUskX{G3b9-KZt3cNF zI3CxyrxO-U$G}z$d8!$jQ)7P13X4|HvaR!Qf{4KWruB&WC*-vcAurqDN(0;aN*lD(5L?7E+(%-fg4YbW1 zuN-`aZ5%epR*(FV%^vYXTYl%&);R3}^J^DbHd+N;Yqm(X-9pg41Q>HN8+1~**8K?S z(ifVVn(Wd`FO@8R4!tLPR=0dFa_k~re=h(2;QoW{flDsAWP)s&()H8=OyzLpMwDZW zxh1w(vT3o^y>Xu{zU5L|R&<6{74@^m5xuRg;4}*r^tRZDo|Y=?RNnc z4@;HwvCyzntiAX&Ybxt&mBsyR-mtT*>W_uizT_#(#MeLr#WRtwGN23nQg#c6^W5Hp zH;2PvJN494NsIVD{4ek8{$M-w;MEWL*MI%jlj`d0vkByfd?H=G^#GTT?o~T8nyGvbcvOXz|kS7BA^xNt##K&HSQM&DU%8(UNW!F70YD znpfP#(q$)`U#9mbT3c~fYcA?-RU>=b{KCFA>+UNovi4QWCbuBFwIhd#a-m0puQa&I z$R>sjHjYfcVdI7{Hrj6=9KsJ}4;=Z-lTWRbZ88Rq!hlJvClSwDi-&%fPMV)=wP@QC z+wjyFTRXC!wSblOvXd-Ua-#W#r_j2Ar!HWuYo}U@?n`t3%PqfIW6D(GCqNR$UO~O_u zF(nM9i%+t2(aF%a?$EbxmV%ar3%gp~$X+&g*crC)fgz0PkrKwh zmIvKR#uC=EXU|`M$h!4Fzn#Nge(A4GvLVPHlZ4kmoj_MGewlQO#p>tVyxV?bHHD{J z7=D#1Jl@jfT`XSq1DbFJPa6rYz(r^2l@G2Q9t%&k^vG_O9ofaQ1>kka53IeYuPwOl zG+TAo5c8YYVb{PmA$u(LuQa%lHOyxYWWoQ_Pd{DDKpxZuKLlw0>}Nmw^`HFYC&OBr zo1L#uK4Qr*DaH-!H+F?Ew#_ZJ^-XWj={OAAu`s=TsQc+QnmR==1iB@Ht z9Rb;xf3l@#vI%%jwJrMNFsmu{Ko z?}e*&1C&L;z(5f_x+mBjbrXKQTLBSWmYxR*uYd65#~stXN_L2J_RK6AJ_$-Ng&@|f9^ zy=1c_Eeu|pM|H*SaI%HKna<;ZsTgwHs)BA-{p=0ksMgvt*kHNeEObl!I-&c;e(JN= z+|V=VS!6r?^Pm5`8GK!`m)&~sXaArFzI^o2-?;v|>z>_OTZ`W<@WCbG`02#=;3ILy zL2(8e6THes!~(YTEczCHn@p{(eQ21q7WRfFb#weKB^h0>UF*6{UKh?fjVtgY87l=p zO=*vA=)(^suO;Dw3EHNi-L2}mG2p4jn(+^%M4!M?l>3Z;fBCn4*&o1G2AwvD!-0PB zY)#D#cK-wS&g#sh7TKo{G7s`OSQ3qq4(A%U5HA#MN)(^WzH=?b z_kGs_`CT%+y9V-_2Oad0H8>iZqn=i3A^0VY~;U?oSB^yD| zoIVHEJeqh~=Nfbg`V`<1e8Ok*{8O|~*J{TGv2N7a*7Vm~E!nz|IIYe2s@s_t9`O(f z=xr1~0GN_Z1ixig{1AGkSe^`eh-k9-egau?)|@%X^DemH<^vZRd-vqXTHl^@+ikO> zAw0Om10+1zdW0!x5%$d#vM!zO`dqTE`aO<*)dCHO`1O_vZLs9p8P+oG5vzQ#z*da? zku^Z8(xZAh`AM|NkkOspNw%jPwgNdRhY{gqPaF1ycjF4%Y6ooTdSS5IfPJI3bdW6@ zd!cQ8e4Mo{dfw9E)%d?_tu={#4O@c;eNsGx>~C;EbXt7p)(WoTPB#(V@}S!>@HP3- zKW#qxD@XtTd)Lbc#;-p8=})_SsDWpmdGo4@6^TSVVPWE0LJ2I1$Q@z&QgNHYlwzM0 zUne>h0!v9ERnquN!u4})`Lx?@{vUpAD~iuWzdsAx)L9lC)z`dH-5G1-AI4ia$~s(a zr&l@_@W)QzDUZ8d;405sMYCe)iS6axEmDf^P<}dgt+Q-P>AAN2h6`=Ylya+I^{S;} z4bb8U&xIYHrYIaD=fPJya|%BY&m1B~L%0&{N)q2yQ&Vlk13dyt4%+H>m@4@7dUNPA zBMSnnV8;#}Y*@kAtf??>+i82G2;)pK)AtYOSa);PASwUwTLT??Dp2<%;>dRxutUbejaOj|PXa@+d)y_Uw;l+J90 zM~0oPs|^|^9YnIdVr*Rh&@Ug@q*r+`*{aq>eCZAiwT z^~g!`5fj>rPHs*ImmZ!%rjk9%Lq|_$LimpBtzp4SwrJATwqe|PwzZ_E#mD!y_-JrZ zjvQW&&0q{Rg)!LEM`Qcsm|uph&RDBCT9Tt*lC6X>$zH;h2mMh?pwA_+4pJV0Stc}mw$DV47 zvDGiU^#a>E<3UTctw-mGTWbQoC;J?I;3EepuS0T|^Itn168{3epe4(e#K;L5KrYai zKJ%H+d_Knm6ytcHZHJzG^2xtgx@a-;PWl}2zQn+}_+I)=dY(^=2^y1SCD+n2@fzak zrrP2=uC~h3^RR86MZ7^@ONzggfQM1=u~FELMt8Bq*b^*12Aem2;8f`;mgaMZA@Gzz zt}<+vlCwm=gel4FaeUE%%np_~9xr6~GEeg&BGMs~+G)!kCs3xm+cIJ8N2ihK+3 zB)fxTcdq4PAh@q`>}IqSm`as)!=}o0g{{J4EzZ5Sj_zZN3;NoO3BRyLWU!1^1)p!T zOfpPhM$F0gNSkh+(OYgFDuzROKvgw0*1v!MYrpV?FQ_c2+rIRrFDX{=K-mua;d$px zXsF%lFeN*A7Q0PWal2~Bt@6brk*5@oZeGY*)-JR44;I^^5&dlQ=+mJ^$V`e=k-ey- zho#F;!-w3%V&$hYPVgkKm4*&vz*D-YC#^?7S8_B!_Oy5BqLYxR)hH(V#y`Fq0zA#Wn2PNyZ{pb}F*fDeRH0pat2|?$D*)W(C-;@TEAv zu;>(gEyLa~JOO%wpRnWyjO7XV_>h0VK^vF|fvpHQi$RkT*eD!dgg3_A@l*I*GzL36 zEdoB#j3G z7dcm7xqZ^4ccSQdF5g9RC~!hF#WpA&C|ZRt)6h43SA^D9+S6LVSSxy&i_L>yIr}&mNR_}Vi;snu!G~Ns zj@Mw#`Hg>Iso^J>Km0_?4nN7Y%m{SK5yB0$iIxOIi6Y_`h>?i$yLhqAcOo>3&qj)u z%9kU%D>RUnfKGYvz7+TGL91f41oxJ94VqfUbB^M3(Jnn3yuD>yPg~5KT6p(wt$oeg z^cAu6$O!FO&)U<(MM!4I*+%=*~YTOr_eHmm6)(-|n?pqc61e#K%R6H^~(DMMh%IgDcH<@f7X#UM&q~ z%(dXPWR=k9u2w&`i`9=?@y{x@+h>_;98 zZ39z$CfzMjfDBIaXvqR(qXOb=3XY>4!|(Ww+KA&^d`y;eX27dxU|Z=A@I|u+yD^R> z9o~?y;JXQEY8d+3P7|HY(9#_9g(=C2?lp6V*M4ztYlr4m5|21*)CE>I>oN1aYJ82Y z_=P+dD=2;lw3v^`ru1<*COb{SZkl-eS}>)u^8a+(zyJHc_x{_ImoS5aCG_G{0$Cg) zrM#FB)<5M4L&e=Z^tM*mvWJUpPSF5sF7IVo`dR*$bOHSi9rEe#GKZLZn>7RJKGv1XO|w&T>Wp0F;1=U zkhu64GEm-6lHco?$w&uAz zY|-R0Tm0Zin|;Ss_E!0iZPv&EHg9-uTTaeJ4fg$3XiQrH_l0~F9|q3gkK&0bWWm&M z>}k-f6f#s4dEVogWGeA^e<#f=IgRg}#+>5*NB_VYp|{J+&#?KoU1^^9B5}^`nYi<_ zhw*2nBv{bDNEO%t0~_<`QKM#qts`ZB_~tjisTzaYK1Dmqz4~bV`Q+12JQ?wsNEy** zCKFGMUCJj8UAQ7nSUH}_N^4*BqAePCiM3o0zaziJV=S`FdB6BL<}`Li`aKGE@>t66 zg(=w&WP5A_SBiu3q$5IqeDWB)p}nkqSTAcTJj1Gs&$oF)&$fj(TxzQyE3ullAF}q1 z(=6Ju#NwfK=Et`ZyGd?^cyT&nsf_e5qsbUKmF-X{~7#>hut`C+|&%bUi?qKRM9K>Ba-Mbq^ny7I-O2bS@Zlq!uR^a z_pl!z*Cvs(Lxr7Wx(XglKC5 zJyp$KUEnN-ttdP*JPP}XWH98$EWBQQ;p1Ou0&`18ooAtW zPcm<7ku4Igf4M~$lN|?FDdg5jBy5fKjduF!r;mFNq$wW{r#h?o?S0h?{`ZQ@pKYPm zhWrgq&tr_@3FIqeOQ-uWepzILnrV}5F8;CxuoOm5ZypVO0xNAJpff{HH*a{qK(-R? zLMC#yZa$ZcFJmC?@K*PaP$&aBQH(GSlH2iPtEt|!c2n|Z5WAwfH z8(RhgkP(LB)KBx`@MI_|cG+Ye|L~Ng>>l7JUz;cezVu!;?-cg7IPzkYb3{3xe8zF+ zON{eJeCQpzZ6dVUnwGy{%kLd#b4LtxGFn4%FXm7%U(({;ie+`Ul1=Uu%c3tzb`-YK z*mkAwrjf&ZaO5GACD0cW_@+|OuxM!?TTybhg%>?b-?lk^AbC~(udrk<(Oc$05+1pJ z-I^42;JV2!zYQ2i@3H^d)$JwzTQz&#Ms34?&A;B?`|jPlU(z0GBYs8v!*evi6YpcJ z6oZ$>hLH6tt?G?iZT;A@>EB+=U-}O^<6&crA#;S0uR`RsB#@zHV|4N7(6BUevd`zj zl`k8k_LBPb1!+DKPLZ3 z@S2_b0)O%OMZ9jM-xbDW<4Qrdq%&r6v?~ekZ@~_;2y8XX z`U|%CCi%=?I0qJ)qQ(-qI>D;!w{h%!?8;E?{$JxQ9Bgyv>bB#O%=CYg5^m!UOB|OEz znEbwhpAUV%=tSmfH{{sfwx+DVEx!F{Ry*xp%QUQn7a=1-I}~4|+=Mh^t0C4V1n9NK zUd@ethfnp5{CD|SZ#!&y)3N10;#mH$`$q1K4%2+bRdESk3Y;gX13`|^<`*a0^n!D2 z)v!L+j=eVwHWJACS$JXwJb7R!#`DPz?0iw!>C&!AZ#>0}=7TM74D$?oeLeR18Kpn8 z*5xmOA$SFJD#18-$XO2g^nrYgap>~IJ8oafn9F~-t;WT^!oYqw&u6Dje{FM99A7*% zHwJ%D?3D5ZlzSY{CXrV*TkY(}ZNbPv$m9K?{XLm?U4 z+UQf|I%MU0fxg8HRX?GhEf{-|RsHP_^FzxSyE^o1YPY~=!{91`Y~*5mc^wRV9tZn> zFJuX8!c~g?kX$Jm5&>V~R14z{UD>+As;As#D@R{q>#<>kkO$J(LVVfT;Bzhb(&OlX z9rZ-nRMcdPkliK89Lyq@hOoa@j_+@8PB_n6)=agu*GPYO^n0KiCb*9TV-Z0%X%DyA zph0JjBxm45Z`-=*<_Ys6N$LVYpW^6%s-+^iS2=x(n@S_!G*>)jGl_2t6F-?4#$01= zdcsRFHc0XelK9MoCD)MGwHI$xlkF)b`y#I!3a}>?Utnt=9&O3Z3$WX4buxGueLKqa zTJe>&Gd~qy>SDH1_!%8R$^X@$@8?ax%Hb-B0XTuI7NtKzK)jtkYDS(8AuD8Kt>|>K zZT0F^lzHVHQ z;ly7Iy#Q!2?npTj8f(RG#E|o&*_dU+8?5&AyKF&uKidi&N)h9lV(z(Ej1IWU<4bav zU(y}?l#$dx+&u*DtYEzrW7v(DVG! z*c9-wg|P?5(dC>CiuvXtM+LDe^eMPfY>Keucq95z3%cQ|5$9UP?}u4p!))M#4-wwl z4xVD@XquN<>PD_)Mo=mh^E4;0l>!^OCY)rC^N%sk86h257Q6kzUn?Qbq8S3|K z+m2x2C%_R{gKh+`g)#ooPolM92gVbVS)m62ZKeUKNE8uUWl1m9rguXmF)m(b}5L zrdno6`b0Ecbl%xcuocDpMr)h@gjI|<4OsyGR|KZug9-X7Ths%8Ek0y0)CxUn2UBTR zCqeXzD;t5)sP=)bE%{0|eIZQzMs~~; zVgzT5d}p8)0k9@I96F z0yF4|(4`D^g%os3@en@!mT|bk-&ouWdG&PWV{drp>D24$W_9I#?9H*~S*q?GWL;`i zINUPh;^^zhDAJV^jNMH)-ne*oJZU$d?H8HsvBw^(RPAb4FBlu4avL;>r4^pRd~lhJvd#P6HNzOK(=^2rc`(pY>3aqGXZmbuSiI|$|G z`3C`zeTm2&?jO7XHElgfbtTB8e%n6ccSWeidH!rps)|UlHV@@tzwecnkU8~L!J$dQcM)_ zNKS6zdBKj;@p!LeQFxu~RZ;Yx1mBC$>hX!Kz-Jg;{wh91a=6sjj-Tu>F8gxG-m4?u@GPqv)7PTJYsfw?|Dwx_rQh;AQZZB$@Q=zWeUm!r1N>C!hP==Z;p( zV_-k*Kl$dHZ)UdD+<>Mko>=wqL&yp#{A6v5U$PaYKc@?Ug62r0mwa{1IKMGeK{+6TsTlLG9r>XRxpN(Uj<%&QAg?uA z6FOfD<|pNq|1;uBM9RrujJ3;0Vs1+7kc_BUP}T5`K)2G=U9NiWPU6A_f-B-?i04X= z`T;gl<|1@SywAltV!u*6v*Zqs+@#p}uFTo))F0_*wQt-{pVpy&Cb45_%(xbDCi0=H zj>^q9->i6=4`fpw(UK)gBEppHMH*|7a&{I;agX?Fn%?}AEy6Akkz6HzF_;O#+oI4b z*{BjZ*-G!l6Qu)&hf~XdIP?|8*o)to%zSFFt>#h70N-zd^CN@&vu^qMqP-py%Lq>{ zYY8!G@W@v5lDOo6=1o@h@HpEzxIc8M5B@|jg)W%qgO2}U`*QYpu$08cs=Oq{L9}7h zT0$;ts(HS(CmNg#FF8wjiEa$pS65g2#9^xT_xrw`c;bl{2;0JVzCQvVE54dU#4;`G zZRv!|td2RQS{lMsTDB=@lju{B8-<(>mRx)Y-x1a#)O=laT_3A|X%aSeniDlYi) ztFN#`8gmD}=Y09<6UhjY3y9OGeRZ;}8BDEVVkKk5{wj_$f!>lLo-$cT&0+Xn9KPpx zA9PFfN_K@B>ZLE8c%7xdWgM9z#Cap&$!ETDW@fGcw)dNUoo}*11S1)Yr8_GJ;Iw?A z&-7`>mVc*DIvx++=Z{LY3qI9(^4|;YS?_lD1o`pZai12RXj-dBHF-ThN*v@gTTpg^ zHI6(D`5ZkO`N8E`Ag89W;mB|3{5{aA6fFV1LglAe-Kc){4nDNx=C{Erc2W3?^2PGD zHTmi{Y}}X@PkKMG^}TO>YfwXL6EXSNhS4FEqZMZ|IsFB@daQ1)&AsI^+gjKkUf%<| z3wg)L~=G;4YP_bqQB-64@Ha(HmWdv8ROi#hR*+9 zah0C~^6twL?`SMz<#J(}BN=2^gk?t?v*20lL}-l))$DfhSoFW>NRZFY+{Txzx-ZhF zl!u&#Po>~s9!(NAgB8$%09JeTS6EE)?DDZH=9cN~?9D!FW7N-l#O4>^zsI&GSv^TS zzw%vW8%&h;1Xsk`(8nH_ii0T~kCgVXwP0)G3pet7_*5keL#ODB9JaU?Iuh$dUAhOj zIutqo>wECMfB2QVZ<+W`4BJ!)+iluMmZWbZ^u5+3%BHKWaVE9)OU|&?kv)-_BvT=; z;{$X##x9qxC$Yk(;9Die1)e8+wul6kEs}qIA7#S)wq)Us!elvZ?=xV z60USMHQ*4uV6PaxIQq7leC3KgP!6_gH7YMQlf`BU|A`S}BD-9YI1?0M#szH+rOG2- zQ9dNu?({G4ATdt54((ktqT>zm*`92Y-{F4TDEE(?skMAq!Id!RHMaDjVynU56Bm|{ z;gu^AgWpBSU-GfP`^1*U<;UjpOfj}D_(JPwe22IGk0skzf>ErE>Ra^({3e@5qT`w0 zn*LT3V94jT?g|&%Y)-rS!p@H8>=fH-l~CXFYt?Hvx;RL!)S=j0%^}6>X|1AcbgeC& z^lMuO&-3snq=!*&5r1fyrZ~K`<9$7x>>iPRjLex*?M>=YSNyRAnMyh1@-Z^&!Ikoz z8NGm;=(+b|%l}Nnsdyc6(**acc`qBh>U)Nm6HV~lM(AXFx|!Hd*1SwqQ~!EBeZRtz zElVxYyqelo8!g?wiCRdkR}mw=DIG!ojaV~tzJ)Of(X^&h628q;L!aYXG}%YCd%ZjV z5GndX@mqox(6%;kA7%V~d>PR-(`<1mH62QNT57EFtBHLtB^Fh7cW9H3T;+kQ6ucu_ z)Cb!H_#1PY&4zz&S@0C+4ZC}m3IP!@C>tJ9yA5Ll#dUv*&d_**=vcE_=F9OiYHF zDlOzsy;F9fMQdg<|Lb@zXdwL<#MJ~+kT9WmVF$3z56iY+xcWx)pW?DqD^E7ul={`* zV(G*N>a5R)-aclFA1St3xBiM8oeOPt**P`~Tlc);AF{uIoW@IS{-o<|*;BXL)wyeGJLuiv@z_KAyCH&1nZRYxa7|H*$NTIwe^Qg{Da+g#q4 zT2DL|J{jeXXNXxyV{4B=gWH%tt>XVh_)4)U#E6qzd3`^teCaOws}^j+|M(~A1gXXG zK8GoWU~lLfvu(>rYU?2% zwiWh<-u5IWwI_b^9{4GHkT2NVs+kLGiU!$?>&WdY`H8K#yTGEW=HOq~nsU-NBG$;< z3yH4^Q}E0DoY3=hcFPJ^^oe93)hlY^IT~mYfTsA*#Mb3D?S_l3aU3*CeapS3X;I`V z)fh2kS=AIs5F4MNC6Kx5$MmwdN1bclmN}f07-`~kgkR-Nd*D{GSEkIi;zh~kA)|Zdr(E6>2+tu;s1P>1CR`j2y-3YL|=*4CY=4c z!D{9_jZgMxRxxytH4LQ=8##a}Y=^0lr=z#_Vb1roB(k9NDPOq_@NIYRYq8gasF9!lWOjdd!aXM9bX4Ww*RFk z=xfa_rZxR-E~Z;?psLZ3CN6N}OLy3E)mEbaGvx0oc1AS?lJvLy(ms73FX@9k!d#b4 z3tF~>Ia2$^-Qcx>hVPY;m3|T>K1;bFwKY}t)vtcF`%bX&UJkvhX2duC;~QO~?Zl>w zUNPcuVV;w{>;TNsx;O48juyVoJu5FMik_=6@p+DP5%ig72=lxxJYx!5R#f&+))c9F z{T|B@bCmRB$kL)#JYuewweQ$=)$=;8?{(br$0R+=5~Q3U<)frL;>+7s*|Nt**}_rh z+ZJ?;Cg@Lu9MxnYa$!+#8kl0Q_<%NKl?G&!cIJDmh_NB2$HldhPwz87QbqWtSYM(M z-o75J{q_1DR`L9e(7~156g5sgo=afhE_9dM?=yca_mTUY*E`$jXK_+B{Z${q#$8iv zaU7W_xpe{gN6=XMSanZSpCXGL+NibVE8nn;dBB=w!d05O0$LZSnplS=_g{q!P)YwN zzXaOHy~a4Xq5Io@hxr4+QTNdfe^f(N&0$LzqfcDpY-q0VU8Fj{#;Ttw zLN~;AgAOQcWzn~@<>c^#s|Yfc(-F}X{c-3~tmD#%4R!w5b1c?CP9?lhYud%&*^2Si zda_AmhKC+{Xbqs+E4^B8A`M_s6mZ z<|(#;T;z&duC`?TY71dE;f}D$z-M>}%^m4?4lBIp?Yo}4<5LB%_2fG?jxY`aYBVlC zV$7oJUbD4h&N0?A6IKmd=R>;95KoAJ}n0RR+RLynz@hCSIV&kNGSpBO9F|IixR?{zx@zfnM|pt<7duoe1+zCrd4uYcVZFb=U1=p5*K zaqy&iR8DR$Vt(*i^jGx-vdX7qJS$3|0~=lkFl`wAkkjZRzP1vx@{m`OA2hH0Le@Y1 zomEY_)tVPgK~{PP+rmm0BQCwqYg=ux+IiMq@uF3e1GsR~uWatd5L@~T=CD*A26l9)D!G_+j%btRwB%P3|X7G zK1MEcL8!33WY@|u#_$09+$_-0%JjqzpmiM+LkB-1zw-p>kgZp!{ z0G*tB4YQWxcfb4H3-SQ^@|VB-mHcb%v5Wp^$M>Ci4Uhe~e9X@04gZrpM)M>C#?r(f zu6gYbwuJl`$x*6Jr5q9YtX$kRd{A^N$zC;{^tut7-MagJi%DTU=fJMOxn%!zHuc?i z@J_ov$01C$&&4;jZnyONIP^g=l78J{tC(=P)er~J28No5``(JIy{hSak5QIZ7p>{-n{J^ zdyD!HORwu@O~cW{uEBqW-)a%GZP`LC!xO?WA=A5uffA7uae#*!wr&;o! zk1(scDy{e`(}^{B*KI2YrGcrqKOdo*~%spjEEE7RO|RS3SU4)FY2=o^Kw{<7#KPS&kOXaHuZl0}nh< zzb829?024c;)%_QUy!ev2`38!egtd*$a&ewCTcYP0)HT~)hO({U?*>1&|Wmkhkp6s z$%p2)mGpLbq;YHuN%Vd31j!ugqx|2__OJUXf3T(jP3Ru6TCP?CHp9lbk5Q8pJxO^4 zL(r=W2HKqBORRp{pU?|8F(+krz&;Am1TJ73?!*h(VcnkNimB9V*`)O^3oaZF32w+BEre_VxD3nKNfLs$azmnQ)H2U{T~{#8O)qq2FG>I?26|tFZOL z{}fl97N)?Mdtub-_io)ow+yt*5aGU5yJkTq9l^B#S65T zom)0&Upf~}d-<63nRH^wxbojLgH6R>$J5w|k%7a_i*3#yerIc`9aDRK7n^ZaZ>xi5 zC8<>vtS2D|JBsD**I{ID9-T+lohtv<7g3%eRPnN{#`YWqHwkF0tI;UFCrssVr8+292DYHNbvK-4>!b`SXmhUaMvmJ6n>y+j$k^2U_mL(UFNZ5PcA6F3duO*`8W|cna~*Us?X@;ueCupm zGWu)_QTsAZox&K{@f@zOn>vhPTLf3WbSTyft)4i<{CJBsF&^pz2HRmu{?2eX zY}BAR_8;J?Q#oS94t_(q5&0NC#g3rXS{=_3X8q5mHy@%t-5JDsLUR?jptv${WnfDB zpvgW5y)|@Ptx>aX>{%At@EUX?j*XJO6;Gfq6fXc}=xoor?`y7WR5V{T$D-(7K6231{PDg{bB_tz($!!gcNN&}8x3;~*JohJ_ z#WDG_ByY?27YCye*$|M&G9;aO#M*?xs>ij`jl>*`y~3)m>T7?y@@#9b!Cr?xpz8{M znhH*~+#6i+8y zJ;55+O(i{S1vyXYNG<*+_$hM)qU*3P`9OT?f(tHC?e1M}(4Wuk^242c&=+5`^0=HY zfXBXI@-LHTn?PQaU)d{SP9Vek;3^5d6)%k7JDkrv%1|#l z=K+gZj${7{(UY1leHZyg#n?~xDMz-NTQ6oytsN|2ecFD&}~WyCBIYeS!^ z7KSFhWEjW796#s({ zz3Xr(z05aWy4U)SP?tlVJEqUxwYhU>gmvDa>FmnaV%l5Qi`becC)#tDpKpJgSc;88 zdLbN?IV#?%ZrHnxt1<4Po3dYse=CMU^kx0j$I!d+7f>rD&Df+F7u7bE3~!7}hVfCp zP742FJNT*~hb+2gI&pOg_-ZiLdHG7bYV_#QFYPsmcJjlLl9I=DpOOXCGU(SC)@)IJ zaVlMJE09e$7hw;=H&aI)>Nwa>VQ);~169m=vZx2N4Ed1b)rG_YOesYqtaCb%e2eOo zKt>J*Bp98}^|G7pba5sLKLGcsRNSUMako7=<~LTqU<&uyWbO2qGy`pWJ&gb0w(^*A zdq=MrALvmwi4Ck_y}fXx8H5{#HBhX=^1;MXQL3N2>yl{LrE& z_*+!3yHhgMPT;id(CxS1{&R0tidg*oTw0$5KNM54?YzyBig5R)P$=UGiGD~#cp#}w}EC4UcBJiSs3pZxQx->Nl% zR@{2Ag-VXculya$4F9fWil}1+z4ef((#VBcmpug58pxkm0sU=SHU*TR*21begTSNf^ed~r1%=|@*@Gl^ZlsS>-?rPNsI0nZ~J2Oqf)elqaI z3}cZdUN?<>%a=S{)D3@TU#ouM4tR4jJ%_}=d=Kj$We)^b6%`fo6YeuyVH15Vk1OfM zT8kUiJdihIn{7HZt5?GJDyjWaiyg2Dn|3p{?Uv!ESsOKWLbO(1H;lj!g|6LFIi2{P z7Wfst4g-)Z9iu-KcdAZTAMZ^sYq+BH1)3J)g$~C_q|0gim;p{V2*yrl*SGi2mY;um z3U^)hND=TSWKtc|<@qDthiBBu*-pRQTWoP2*)E+O({We%2e4zbwb+c&S6OA*`N+M! zi4p5TExMlMXHr8MJ*$P*NUOouw2?Z2v#vpB{>yEgtOc+JaYf2q&(o__t5)q(Ts`>U zgO6{sF>sxju1UY(Cb0UVX0r#5~dq-d=|<*sP0NAVu%M(P{!Sr=!! z?aZCN!k*ub@S(Xu(>-xz&3NK_w zADPrg_RBZb=_9+I-;rm5JyJC_m1i&8s)Q=(Opdp@K?r!2tO4quythPs7c_A&@k-ew zMBDTY4NO3roS#kin!QzHwN@rGKIby=42sol_3T+ ztyoZGCIa!1HCx&9TJQB*vDn>up)#-q6B!2`^^QJ-^7^(Y@m-%-D%)5s9ms1)NHLw-`M zz3N*jE=FsN$F#nv?2;HhWfv0im<{~hel3m1zS_2pEmsh2(wxwhgab8wu4iyL0qCo$ zv*B}Z5!N&dp~-%xb2b@gj$&tx^LdoH8q(ZzPr;dkPI4C!7G?7nw(^^< zr`EYG_?7GD9eyYqljzCDjT`qVy*h8upfbfD3sbJX;||w-Z*Sk-1CADfr9d7MmN>gQ zjiz;)vM@HFq=9mBs_$d5A@)S;J!YVr9&^#hXBnr(u%Baj4R{be4aD50k=A(c%S~qs zu(W-zW5HDdgt=T+XqI9!6*nw?6HXBGgl{|p-AJZb4~3cuqA^K)(=pj1m@}$ZuKb7y z=hLI>Ie6=ua3w0NYj@m&>vlY{=dT^T?eKN+sVHl>KmYvmifPzq^onzR`i~Diw82x2 zD|pQQ+tmM>+0aCFoMa9zr%R&lIQyY|FRtz$wS0Z-LCp(ZvCXeewvAIJxtyL&Z%wvX z^;=+X9p9@(z9Rpc^GOejaSn%59$;VND&Hp>Pc@CRt|dK3o&?$6aaRR#Qi^$xRavNZ zmTh`xvaNsaCR_K!Slj&kMBDWC!xq}~7HjTo;{IE~oamPH8e}HUDS1))BvTCjsC_^q zdHJqw$M^60u#<0uGx_mY>*2DUbF{rbD&Nl&k3U|moU*)bsOt&K+wEH5fMfaA$=m1a z!5An9)A2m`n(RBW4@970Zk-q6=@P6vvuf%C_Imk6*z(S@P2>t}X05Ag)&*Ej?!$C+ z&qeoLX$?#M0?(;LZejf!MmNM@IGRPLbDR5i%biotu1)mT$?Ck4HYho;J&Lc8*wx0x zuiE1KhuYia=h<9h=qqSzh@;qWxKrLh8w1Vo_$HHI)d@&(xRfLwrtswk-t!U zSC~>;l_``pt3)Erm;NjH?Nn#P8#x(~uc4BntMK$0t7wmCQd(iRbiRzI$v zsh&u(oOSqO1%eo$~HRXaL}32*v(VuG&&y- z;GUT`0o6sP;U7jzo`v{QYFDj$=?}K7{1WP$uudg;yYWJDXNtJzq8{XI^uPw#llRoq zA-8w>@Uv~fpC_0XUI&kCW==r6K&$-9lKIsFy45*c=}CkcjbDE3^j=NxsI>Zta;0Ne4#O{Ns8S#R)}wP zco*z_-Ki0Os=dv6l#3oKv`iGf2sWXB^r{fNkGBrE5_V;imHh<6F>?ewQ*W^1h1+d@ z={dv>0kWY?~`>Z(UsL#GF0zt?fb9dWa_ufPBO z?+@(Lr_a?L`C?}n4&CJVlrWWHGD5bPld?x)o2Yu*7EJs#^~}%0m)(cFo>N#OjaV<% z`0$8Pjlu`RfQ$tJ~G0AFRy zwf@_Obvpul4^*AXJe}1ubZVo6#Q#*A(8ZV&ub!ob?>cfwSB*K3oV`By=X;srXFc+d zQ;H#iHtATrl$ekb{M{}lRpCq1^dYH3sxf1%>mo=vF!xK-ZSUSbpDgWEyikJ`O259|o z`Gj4)szTxr%88vGaWZk!XW5qdPa?}VqF`*Fr`{SA;w6xcoJ*hvZY1mTjSU> z&DF=p@8~lgiak~Cl5zkPr=~bBt??ezt5fVP{A4t7I_2H1ax}RTk5c1;HJMn^gLqM7 zQg9`n#VqJx1KN9VDHl-n4W!Si20$1kHUfQVUG|bKEj|<6D;`_(L9qmiDIlIeI4bGR zeRsDkn9CqzrD;CzeLf4;wmikw{T$7jIC0{- z{f&uzbpB6NRaIf?l1>LLkddG>8~r6amGnS|E98=@7w)!=)G=y8Cih04V#<39@|3_3 zxC(00bu+i-Ht|GS*D6?3oLIJT5UQT*cUnhXs7G zGdCQ3SL3SDRb54CZW?dVTpwFO#S%8OHOfb!&2 z-$MCInk$VZz3i=FXIXg7TxtlRCpjR=BO!msHf>~&Zd~J{Ij^yyPP%0BkHq%(S6@Yy z@Eo1hGjwX>>xwPG#K2f5vG>&3@_R;5>u`W2z=!yQ;vOBAz?A4#9#=XpOsTe}YSsx` z*>c7YS-ugSYbCj5O{->L=MK3zYKhgtm7`be#aH$gSNf@J?FsG~4;8pVRwo859j~#4 zcl_L1#-QhcpSW-ZzY}JH7SJluC*eu7Omr;3Q$V{!!y;gD@o?6se(lek7r_%5R8~@1 zGxio&ZEbD#o$q|-C;I?Y`z*#`clSbE;bhr7@^eC?wI`c;O5LT}d6FB5-nA5U*4`QY zGvuWHU<6%{SfZdtYYqM#mkZ}{0#;Z-(^c>XZ=<8b6~Bl;vcszU=; zNwB6IOYuRM9|z8Sn#&U>W^x@d2a6uNiTN4$Z4I$Ibu*pQ$uU)jnQXVlsXBLq4M-dH8~W&nE*?UhH=lrkZ6{gUW@-Jrc=Mk z5W`l<+?w;yIPMu;09u8Gl>3$KGPvzMoe}C=@xYf~emRe;!#)aYTMECTv3Xr~xAQ;O z8QXBBStz3A=oJ=bay(c6ekgU7sUrlfN+MgiI_2;@t<{kvCe5u+&pt)Wsy_3ukH)cO zNiWP23!NdZEm3f)ttZx~VZpPUzZDq^`2vATa*A{}HDPP-a25Pa`m1;<5&-m8vN2;G z*=(~X{M5puW&h|!ohGm)e9`jx-&$i=^_+yKyd3ZNqN7`)U+_(EwVr&yif4(tQ_d!| z%3+s&axtlU3M%?nxYE2IF=B*l=pUI)>)0(^xG*9SSlh3mRY7cf@W~ zK6!)Hl1J7|%xMbUTx)hH*5BcWyfTki=cF2RNjU++lWK`6UoKh1`$FUs>QZeg8erk7 z8TfpOfd)s)S5QrS*&I|e!%ufo$LR22x_`ybik}Ljni?+782ND714F4R(i_^;3wssx z2l^V7ti@h+ob*~UR|@Zciox! zAReN8z_ZUjyZj>qs@#oEnlx#HaHV;%Cwx#tq_L4s#i#_do%t>sbK|0CY$1NXwjyM! z62;A)$k?!Uqifg@!C4ylBLjZ4wv}*{EFy**`(lI^fOG2EI{#Gr@pPsNE7cKHG1jV{5T=r=7QuYuKVbR7~dWRp=mByZof?eNx( zf$*eFd}Qs~wYIPGm){SJVtYSdI-y!;HhK$GVX2hTPk+e@AlY}?*b7-d`wZ3=K;|eC z|05TJaZyXiJ_=om<8Ny(?1lb^ZlyYt@QyI_Y)kp+HhoBci%gd;1#gui2sM-~z+Fa{ zqU|^q9NM#u9}1Jot96!EfJ!)3ko(HKh&L{^*UB$;vA+>`VT|?WQ~02g%!@<`>v<9* z+)h4r1iZEKEG^)vjde$2W4rKt_&@pnCe~Wo`uuI!a%)&C2&*0#NfQ{actF+7*^$!j z_$->+d0AKU{ZD`T)0SP~;6MK3KmL1%&px%o`w#N%$CoWzmf1U8q36&Kim6mBX^%Pz zjnn~p=Q?ay)Qya@K4y|QZ66&ki7zb*9Sifio>qe`yP36U(?vgE4Fa$Qotu3PYr;H( zABS3p4zSQFq$|J>%wPS@x4lJ|WMd99tdg|*Hq@fB>Oer7#2X4|>{By=i9oyo+g{#H#s!<=!KlLt`&&u*pmA934U zgVV#mSqB_kN!rje>}nIP^4OA{ec-@>BXcXc-;>Q`@!>WIWZhreoaW#rIAw$glH zX>MYH@uB-*i#Qk6zS{WwIBIqG#m3p2`5=7)UWi=ShK#ip8%ixTKb7m31an$5Hb%V9 zy6ev%76`e2>2%8w6A=NsuEsKsXX&%jpB00yUfUB~X*?89ysy@X*wxtYgX6pOb^q6Y z{ntr~GyF&Ll^e@#f9N6X3u$B(dh1g9Kjzj56`$gUa#+?y)ife`jPbIQn-D7g?Fbc^ zb$NWeRc^b+Ty_lE9Fk2NZQ0}-pu6~=2cO1$qT7x<4qk^38a*Q_dkFQ|yzAlVL&@bU z_>oO7`8jo#Uf|R`Kj%^`TAXvsFE21jbXT1x&0ampt~T*L%~kOa)`6S855|4RQ|uB4 zJAU!KzE?aA^^FMC+}#uJ*c0j@577z>4gD2HcUK)D~;~I%1nH$Yk_vS0C4{@2T9DbH<#s<=e&Q}Y@8it%^ z_0)r}8hp0RyY^h0f7@^iubYRyr(6>vs_6sA?{v%1D-kQ@iO9~syE|9^i%zLEHa6P# zzW2Q!z1Okb;rGJSd$zCcaL(Pnz4X#c3pn7b*hXbN>Dm;{dP%B<04(y$f(Gn&NP_}V>kRtn2*NXTMY$a* z;&fuzIqKPm^&UFpdr|J4oc{!Q#?&iMlWUbE|3ft`6~`3=SI)M^YXk!N1M3yCoSQE~ zuz>pyHt|5=O6%Kx^PAs1^&xF2 zL%r9SI_kmk;O899y~y23a5lYp_wn3ia^LFQPWPN|9nTxwJAb(c-Kl7q^Bd{vy1ahm zUWk7RC%XUOU+^UQRsKGmhQJwgtm9R9vzsk1Tggt{vuDqr?rzXN?4J<}c3g9Fb8bv` z^U%BfDA%9tT-Uqr>ew!}+(*Hv@SWWIV7zzpyIq{FGlzBdj#t?JK0tslzWo`3GjIQX z?%TQVzVCzY*#r9y^7d)rtFp4v-s^gSdy9#E@Vi6F>zJwM z*l}}m26Q=)d3OBt;QM{m13T+y9aF4Hmo8nd+XrL(L7d`n>TvBco;#~pY12LXzGaLtd4-tLTE2~#>Qy?4iF&OiIX{d?p zY`}m4*YATde&0`_b)ZFWg`Gf?kwz1|^3tmLLJiBttr&LXx5?Recp=+jpj&Idy}4iK z6>E2<1M81^al5S{usg`c7v$v7g}jl(5y!5T!4DRTVhba_LUvhK2eCtU33KDek6#QZ zIwiZa*7LuoXFnw!i=Bc53%6cd;v4A55{qG5lym zM{ToP#O&ip^zBfrXQcT-_A;^|S^<9Jxz* zQ=}vZIOAMInmC!uo$NsS<6^slcvPa%tdY6df}b$TT+Be1HqU#)-n!ue)@ePRI>CKd zV{{;OTowi4@ZO?@K zlxSOFuZ_FdHd8|=T-?Lbti#k!yn2|qwt4jFHe=jRtgU)FYhrC-z7aEyoF%!!#Wm4E z>Z720^di^XkpRKDs|8;QzPS5A`@lZc&1v2R{7Lt{-8Zf0;t0>Djt1cqO@$Y{?mS=5i#g^v!L1KmIm2ma-uYY}? zt|z#^@ut{mXS)(@60Y389NogU650HY%`Q2YnmxpNl4lw%CbzEiH1mn`4wK)zfto_o z%Fkup+K0(M*+dKy^=N&?<|*z`b)SM-9jfc6K2|)E;!CkcxG~6eAwBpZ-UJyMUF7o3 z6dN1Vein{s#L8rlGi8-hPFgGSRD^vR8lG-kVr%|10{QfGYaG+t!sP2@p-(>jsodxo zc_fV`J#EG0A$*^8j=4yF#;I?FE7dvZ*RS74$~G!oDeqDFzT0u7nL>l#M>hQ3mcjE< z)A>UUDY^hR*gQ>*1me1`Q&-|&~uO2yd=3K3FV`JBdt#$ zyM*ffD^F8$hgfFE?Zb*OM`ndBx>(E-oGrjJa4w=A@q0t#{**oliV+sB(ra zz4y1a9vam?hJ01#WsTmP!`YrwWHnLVdV-N$+zts!cM1K`^qedzD zy3i_9y;AZM+d19_wwBy>A$?TIIYr5H8<*5&WwXBi`s+VZxVrel3m=hh(?{lZH3}RR z4(L?|p4PnJ&$enf>*6UVnf#_u8MrFxV_FAV>zMgqD@#oxt&bN2bE{dK`SmeBv~|<( zCSG?9GAs5z=w1|T#gHA8s;{+nWRKp~fBAm=fcFkI+{2E0@g@CpgY4)NQN`FWH8-?A zQ!BhZ!859^s<&mH)v$Kxl9Kbtj~r-CWqs%~`l^^3H00^VSeM9yK4praUq#gSE9vgA zHGlkhlsCmk>tvB!M{r5$>7rK!1qB}|T>bBFelywCN=_@5OgfyXFqDKoQzf6Ozu#w7 zg##V^jzP!U=_6O4fH@YY4or&rLTU1WGvsFa)PxCxv1^{J4M?B@<(;?7V;?@~+$xqVpBD@aMXAx*riW=|P z@LFqm>pok3%O$q8v>!Q=^b>vMAwMbqK7$;gbsiHVyHl&J8}$Q}ryJl+G;8j-b1d1m zl75i`O#KkZxOrUNbkj{ADO|C>>LbFHvoi=w4o?BS$}$JnzjQnL%9->Fd2-;YnZAl3 zXR7v)YIbN1Pu1;6kx%X#7*ib#4;tS%tgkH{a;B~M^H__q-n*ZvCl3s{gB%2{o1Mp1 zp8M_!R_{03geT1^W|wQauZ*K3lBuu*Gyj@>55Eg`PV6utYDaFo?{~Hyp4ZNNOJYAr zQ|~rSUVTQGBG)j3tdId$@gg;7JaryaL!b?ess7(Q)s1dif_@Lc+%YG^#S zFXf;8eXgsnymGScJ8!#^ejIQpdX|1$^URG_Mg6@DvVjME60Sle$nNw-*y)7STH$z- zbucqvD@zV=R<%OGn@``xseQb%u&2E}ZjfzxeUfF`S6j?$WSw1dqM%z`F4u>mlfjjD z#L3WJi5Uv6(PxKpI89mnrPs-Ivdv(;-on{r2hq8wE8CHkgB}6{7*3C|` zeqf50#E#%0mnNW9aptA!<-};hl`s|oS0Rq84$=Z@z{l$ra-9zH71wuj54+;sci;Vy zqF2{lcim)RO7vEv`Yv3h!&dR+7;r`XJ^IGOm(zw06E5kE9@E2G=*t$~3uB^PzN1@O z(-T>q8Y}hGHVAR7b>y+wZMxg0VLfeW=})ct^(QReQVstQZ=v(l+&IeaC0fB-+6T8O z21rfxiaQa%R_+;nluoo+%j%i7fL!i(hEb29>@=S5IB2x$f}BF`HhqFD+6u1Pi;kyG z2z48vNgh~Iy_1;eK6{_*9>(pge&pDco8P|D~qR@8roe%cX%+Dk| zAO57oP+fb zW^tTv_6a0ZLF?%&7S*z?VW#cRMi#7m(mt%ry`xPb!42xOz}sV z5JS~LMFWbq)fTlX_=nTFgjIvd2D6)l2u|XUj@p@ardqT*B8B!3TXYIom3DAQP&5*L z6%~R862XZOAiqfVcXIpr-nTE=U01~^3Tb(nm$&b`_uU`ooqO&%_uf6{{@ctte-VBv zFE#DN0XP`MPf>7^>8UQX{!BXQL-PG4-s;8n>*G}X6Y-Hw8=IYb@lzjs6$b0WR@}xo zcx3k*=%1p%Fkfjbk)EP`;vHVMq--Iq- zIozc>NM3E&uwg{;)k6W;)CT$qMW0A_~<`6#Z+V8vCG~s{m?ce^&eDLaG6FtD5(`bkF zkC909^WK<~#Wu9^fl{5at-^)a7^bayuxNZN4&~$KPK)VU|HJi4k=gqps^UPX&)NUir#K+Z)eaNjCG)mx?DIVmkl5U`Rc6@&^ zC({p|WE;kjQ{pWvzmV_SYNnf>UF(?Zi&%M0Wh6GOJ*Dxav9WPP@fE+PU25$?_T)@Y(T9?bkwkhS~bnI zF~`tUcbnP%-2J9=%PLg0J-m-z)<5t)!@6M{e^bN1LwCv)e`%k0l=ZbRu}Wj;tBRqh z^#IXUr`F2caof&~ByA4$X|=;WtsRo&AMuX0cavAwPH~^N?dN%WPv5eB8<pi-)IEi zOau27z;hE#tO^~!hPkT+*wy%x3&W31^u@zF*D;SQdr^v8Qm8?q;iKe`aS)d|0JlwM^HF$Nq&cj&>EIe={61C2JJ-#0vX z6Z`(|HQk$*oA{yk*dNjXf3Pb>?x z_&ovqbP!)XD_5?Jty;AzzIE%?`2PL-EuV?kv`!$Nae2zUy1b?Db-K8&E>8(>Z*P|z zh_nAJrugm3GXx&iy1C|WwbvZ}k8-N+%ziQ-Dt$|BNWlB79jHCTl!!!osQ<^NiefHTuChpz!?8*N%WBAr3OO|ZI zR@%|j)Re%lv)R0Pvpw}0J~ZHF?b@|n_{!PFob}d)3m5(ypVgZuPMkOsUvk&EHqZF+ z<1bxw|NSqv?Aa6PJ;FY`5d6sA%|Uc_@t4+}Li9B|yF}gD@CZ-Cf5=#^A*gL(?!(Bi z5b<0R#9xebH=Diq3U7S2(!BY|?WSqbOtWLbHORBeS;JtT5#wM7zMs3;ceJVc$EK&{ z4aV9}+Jou~$0{y>+ukqx`Vz80k8Kpb^Xg-TF+!(uL;r0KW7prea%PX>`WLn<*v+3Ns%_Jg+p*=jrT&MgCMfzEn#ZPN2!cHXmepDF!IznHU|DuF+ z2-at{KR!yCa2j%oc-x(aO+)><-e1m{^UPUiouycMM|;TrKArQsoa5Z6{=<4d-oe;*n10N< zJ-ncKX{cxU($RgBL=mpB1fH_KtoUs8BJc#f71wwNKSy|PimSJvWFs`pT*JtqSU|lNH6VxPIi2SWT*}jy;j$ks%n`-Jyn0q z)zfZkXVM1;L&T8#XuE0O`l@;7<)=-<^G}%0_7?i4`nW98;pa7eC*#YHnE9($UVb?) zU0D6X>bJB?Byr>xdIK@o2bd3#KfOrk5Hh}xag9Ax$Y#wMiUN8<@=kebyh-uR2!2@@u0tUHm9F~s4X_tYPr+TOamH5~58r>go4 zr~LhE9$r5B92z=Yr2m#KmL3j^+3!K+J3fpZ9G9NJ?av7rxHG?d<@U&>YM4{qL) zP5bukGb>iCIPCR$|HSW0v|oGdiOfvd>ZacVpxZsLeEISN()*?BtDUYrp!R<10kKf| z`Q1$%8ft9;T>u+d`;j7iMvCF@8=01*sug+AQ+c+wwvMQA5Ixq}Cxb2P^f!m2^jYh| z5fq1h*+b|G1Uq3(gWWTnA=z>?+-gLCq|gvrxHNIEg2Y#S^vixu>1>LhqTl}NcNSgHT0WE=Uy%+C zU4E540-Z+8I5=(Ew7a!0-?hz$c#C#Q#!L5fVtt@TKnutBCOAAMy+S&zp4GkhNMkDg zRujZXICR4eH$1BTJyQARi_}|jmuJnI_4v}IOW$S8iuCmKSel40T;9-{XP=#alg93J~EKDt#acW-Q&h0~Y&D}*~oLnUn`Hko6U!*?AJFV!kWy_YBc*~XNE^ld|AAoNL zk=vSYPVjjk3IU{BB-i)D;gFpNYG`Qaz(2)Xg@uJT|F6lVqkTvCKl>hbY$55tg}>Qp z^XAQakvWiq8ZZ0%SzCawEN!7-yr2Cx%z+KcnS;W|$SF5^1Sg zprGJ{vT2TnCb_w}Q(9YF2gF-;El2vecnUg5CWVnl{hZy*|8@3-%#K|xX2lDCKU`T| z{m&^=rj(2uH%@xeDMwS*|3R8lGun8H=reu#^qOEWxO(-fm7Tj=-j8+ncAKscK2@+W zBg{`|epPdQ3A?T(+u4!-@`+qpTDmMF&k=Kr|M?l{3C$teDy?1nZo81>AM9y08=5wo z#m_#s_2%1e|J^y~oKr9kAGcq1!;q`T-S5t^YO~_SV*_7!$Gp3qUA*MkSGEuf^*}d% z?y-&H*xK^b9SVibc48LIo;~~VV^!{P%;#&wsGpRdpYJ{Q+;eZE9;b1Bn^V4*jiUC-lC$Z-WfAycqdPu?4>l({^W6z0w*bOk^(0waFPNiDR7J_ zfD)Z?Bs>`aw_h>#@Tg=JhRRKx?)d~3nTEaXBpgcDCh}xpf;>eQbkhx5EGG*#HVsSI zbYs#m=UJyV>G?SYyO_3%bEG}H$-!y`y6NPW-{TpzG!=8o7`-kPbLui$(9p9^8R>P& zhIKpTrajx{V6`dvCF&f^62YloyxoC3mI%o@C!6NHk|fraOy^OCNvzJriiR;4@~99e zU2F(b>l`duW4;+h?3+wX4?9@>(6b&K2sl{XF!p^H8-|9Sb%^YILF??Z4v`&<%2>>8 ztxy*mt{&idPP#TFaF67(_3pi{wSyOl9n!Gz4pyh{-D9kipL=VsV=R_hWe=}J^-IUT zX|dF1csvf4+6=O^>C&2!6&q#xC?g}?;6FTO@Q$?ld1|MUu08`>lm%0r>(bK|9VM2d zbm@HTIT9L;?uHs*-1bcS*I zYBnsrkg>KOWn$grmyS8*reis_E?Kda(Q#T+FFUx-dy%b*cINH(1U<7P>g?_}nsbvZVVAEVas>WV#WL zWgDZuOf1*qsTa{w${1zpimZf7!K75!&X|G~f!Pj_f(f!>Zy3{~&cnl4t;NKNL+{N| zz-$i~dgnP#G82}bY5A&N$yzNOK_jwQTh0(>>WqWg6vO{)y5U>YD4B zf6pk-XpzS=wP-OITB2ZH9zap=2(uH}sK7Vof+!Fs?SLEG#%eDDa@^S+~uYaz$ zwA}BzBCp05$SbT-Xu zqP#SaH{7mE)T+;J*Zj&kr6tAJ`)Y0}zsJYxx#Z$YFP?MBMTHk#R9t*#;oLj(a!bAb zyDGhRSu(f{w6!hxwDwFL`o^|@YJvMAuM|J5(LavSo<`>bpPGk0O{xe~xS>!KhIh1` G=>899EFwAp literal 0 HcmV?d00001 diff --git a/walk.manifest b/walk.manifest new file mode 100644 index 0000000..0bb6419 --- /dev/null +++ b/walk.manifest @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file