commit 5549b3545e58ce871ee7bc6aa9f5e025ff08daa7 Author: PTyTb Date: Wed Apr 15 08:00:15 2026 +0300 залил 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 0000000..dca514b Binary files /dev/null and b/app.ico differ 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 0000000..9a6878b Binary files /dev/null and b/rsrc.syso differ 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