залил
This commit is contained in:
+26
@@ -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
|
||||||
Generated
+10
@@ -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
|
||||||
Generated
+10
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="GoImports">
|
||||||
|
<option name="excludedPackages">
|
||||||
|
<array>
|
||||||
|
<option value="golang.org/x/net/context" />
|
||||||
|
</array>
|
||||||
|
</option>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="HttpUrlsUsage" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredUrls">
|
||||||
|
<list>
|
||||||
|
<option value="http://%s" />
|
||||||
|
<option value="http://0.0.0.0" />
|
||||||
|
<option value="http://127.0.0.1" />
|
||||||
|
<option value="http://activemq.apache.org/schema/" />
|
||||||
|
<option value="http://cxf.apache.org/schemas/" />
|
||||||
|
<option value="http://java.sun.com/" />
|
||||||
|
<option value="http://javafx.com/fxml" />
|
||||||
|
<option value="http://javafx.com/javafx/" />
|
||||||
|
<option value="http://json-schema.org/draft" />
|
||||||
|
<option value="http://localhost" />
|
||||||
|
<option value="http://maven.apache.org/POM/" />
|
||||||
|
<option value="http://maven.apache.org/xsd/" />
|
||||||
|
<option value="http://primefaces.org/ui" />
|
||||||
|
<option value="http://schema.cloudfoundry.org/spring/" />
|
||||||
|
<option value="http://schemas.xmlsoap.org/" />
|
||||||
|
<option value="http://tiles.apache.org/" />
|
||||||
|
<option value="http://www.ibm.com/webservices/xsd" />
|
||||||
|
<option value="http://www.jboss.com/xml/ns/" />
|
||||||
|
<option value="http://www.jboss.org/j2ee/schema/" />
|
||||||
|
<option value="http://www.springframework.org/schema/" />
|
||||||
|
<option value="http://www.springframework.org/security/tags" />
|
||||||
|
<option value="http://www.springframework.org/tags" />
|
||||||
|
<option value="http://www.thymeleaf.org" />
|
||||||
|
<option value="http://www.w3.org/" />
|
||||||
|
<option value="http://xmlns.jcp.org/" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
||||||
Generated
+8
@@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/stream-bot.iml" filepath="$PROJECT_DIR$/.idea/stream-bot.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+9
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="WEB_MODULE" version="4">
|
||||||
|
<component name="Go" enabled="true" />
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="inheritedJdk" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
</module>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
package ai
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type Provider interface {
|
||||||
|
Ask(ctx context.Context, prompt string) (string, error)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка тегов <AGE/> и <FOLLOW/>
|
||||||
|
template := cmd.Template
|
||||||
|
aiResult := ""
|
||||||
|
if strings.Contains(template, "<AI/>") && 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, "<AGE/>") || strings.Contains(template, "<FOLLOW/>")) {
|
||||||
|
broadcasterID, err := p.twitchAPI.GetBroadcasterID()
|
||||||
|
if err == nil {
|
||||||
|
userID, err := p.twitchAPI.GetUserID(username)
|
||||||
|
if err == nil {
|
||||||
|
if strings.Contains(template, "<AGE/>") {
|
||||||
|
createdAt, err := p.twitchAPI.GetUserCreatedAt(userID)
|
||||||
|
if err != nil {
|
||||||
|
template = strings.ReplaceAll(template, "<AGE/>", "неизвестно")
|
||||||
|
} else {
|
||||||
|
ageStr := twitchapi.FormatDuration(createdAt)
|
||||||
|
template = strings.ReplaceAll(template, "<AGE/>", ageStr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(template, "<FOLLOW/>") {
|
||||||
|
followedAt, err := p.twitchAPI.GetFollowCreatedAt(broadcasterID, userID)
|
||||||
|
if err != nil {
|
||||||
|
template = strings.ReplaceAll(template, "<FOLLOW/>", "не подписан")
|
||||||
|
} else {
|
||||||
|
followStr := twitchapi.FormatDuration(followedAt)
|
||||||
|
template = strings.ReplaceAll(template, "<FOLLOW/>", 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка таймаута (если есть тег <timeout/>)
|
||||||
|
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))]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/>", "@"+username)
|
||||||
|
result = strings.ReplaceAll(result, "<RANDOMUSER/>", getRandomUsername())
|
||||||
|
result = strings.ReplaceAll(result, "<ARG/>", args)
|
||||||
|
result = strings.ReplaceAll(result, "<AI/>", aiResult)
|
||||||
|
|
||||||
|
// Обработка <random>, <song>, <timeout>
|
||||||
|
result, soundFiles, timeoutMinutes = processTags(result)
|
||||||
|
|
||||||
|
// Рекурсивная обработка всех <group>
|
||||||
|
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 обрабатывает теги <random>, <song>, <timeout>
|
||||||
|
func processTags(text string) (string, []string, int) {
|
||||||
|
// <random>
|
||||||
|
randomRe := regexp.MustCompile(`<random\s+s=([-0-9]+)\s+e=([-0-9]+)\s*/>`)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// <song>
|
||||||
|
songRe := regexp.MustCompile(`<song\s+([^>]+)\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 ""
|
||||||
|
})
|
||||||
|
|
||||||
|
// <timeout minutes="X"/>
|
||||||
|
timeoutMinutes := 0
|
||||||
|
timeoutRe := regexp.MustCompile(`<timeout\s+minutes="?([0-9]+)"?\s*/>`)
|
||||||
|
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) {
|
||||||
|
// Обработка <random>
|
||||||
|
randomRe := regexp.MustCompile(`<random\s+s=([-0-9]+)\s+e=([-0-9]+)\s*/>`)
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Обработка <song> с любыми атрибутами
|
||||||
|
songRe := regexp.MustCompile(`<song\s+([^>]+)\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, "<group>") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func processOneGroup(text string) (string, error) {
|
||||||
|
start := strings.Index(text, "<group>")
|
||||||
|
if start == -1 {
|
||||||
|
return text, nil
|
||||||
|
}
|
||||||
|
end := findMatchingClosingTag(text, start)
|
||||||
|
if end == -1 {
|
||||||
|
return "", fmt.Errorf("unclosed <group> at position %d", start)
|
||||||
|
}
|
||||||
|
inner := text[start+7 : end]
|
||||||
|
sections := extractGSections(inner)
|
||||||
|
if len(sections) == 0 {
|
||||||
|
return "", fmt.Errorf("no <g> sections inside <group> at position %d", start)
|
||||||
|
}
|
||||||
|
chosen := sections[rand.Intn(len(sections))]
|
||||||
|
newText := text[:start] + chosen + text[end+8:]
|
||||||
|
return newText, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMatchingClosingTag ищет позицию закрывающего </group> с учётом вложенности
|
||||||
|
func findMatchingClosingTag(text string, start int) int {
|
||||||
|
depth := 1
|
||||||
|
i := start + 7
|
||||||
|
for i < len(text) {
|
||||||
|
if strings.HasPrefix(text[i:], "<group>") {
|
||||||
|
depth++
|
||||||
|
i += 7
|
||||||
|
} else if strings.HasPrefix(text[i:], "</group>") {
|
||||||
|
depth--
|
||||||
|
if depth == 0 {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
i += 8
|
||||||
|
} else {
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractGSections извлекает содержимое всех <g>...</g> на верхнем уровне (не внутри вложенных групп)
|
||||||
|
func extractGSections(s string) []string {
|
||||||
|
var result []string
|
||||||
|
i := 0
|
||||||
|
for i < len(s) {
|
||||||
|
if strings.HasPrefix(s[i:], "<g>") {
|
||||||
|
startContent := i + 3
|
||||||
|
j := startContent
|
||||||
|
depth := 0
|
||||||
|
for j < len(s) {
|
||||||
|
if strings.HasPrefix(s[j:], "<group>") {
|
||||||
|
depth++
|
||||||
|
j += 7
|
||||||
|
} else if strings.HasPrefix(s[j:], "</group>") {
|
||||||
|
if depth > 0 {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
j += 8
|
||||||
|
} else if strings.HasPrefix(s[j:], "</g>") && 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:], "<group>") {
|
||||||
|
// Пропускаем вложенную группу целиком
|
||||||
|
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:], "<group>") {
|
||||||
|
balance++
|
||||||
|
i += 6
|
||||||
|
} else if strings.HasPrefix(template[i:], "</group>") {
|
||||||
|
balance--
|
||||||
|
i += 7
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if balance != 0 {
|
||||||
|
return fmt.Errorf("unbalanced <group> tags")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 := `
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Twitch Auth</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: sans-serif; text-align: center; margin-top: 50px; }
|
||||||
|
.success { color: green; }
|
||||||
|
.error { color: red; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h3>Авторизация Twitch</h3>
|
||||||
|
<p>Обработка токена...</p>
|
||||||
|
<script>
|
||||||
|
const hash = window.location.hash.substring(1);
|
||||||
|
const params = new URLSearchParams(hash);
|
||||||
|
const accessToken = params.get('access_token');
|
||||||
|
const state = params.get('state');
|
||||||
|
if (accessToken && state) {
|
||||||
|
fetch('http://localhost:8080/api/platforms/twitch/auth/callback?token=' + encodeURIComponent(accessToken) + '&state=' + encodeURIComponent(state))
|
||||||
|
.then(res => {
|
||||||
|
if (res.ok) return res.text();
|
||||||
|
throw new Error('Server error: ' + res.status);
|
||||||
|
})
|
||||||
|
.then(data => {
|
||||||
|
document.body.innerHTML = '<h3 class="success">✅ Токен успешно сохранён! Теперь можно закрыть эту вкладку.</h3>';
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
document.body.innerHTML = '<h3 class="error">❌ Ошибка сохранения токена: ' + err.message + '</h3><p>Пожалуйста, закройте это окно и проверьте настройки в боте.</p>';
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
document.body.innerHTML = '<h3 class="error">❌ Токен не получен или отсутствует state</h3>';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
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:
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 := `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Alerts Overlay</title>
|
||||||
|
<style>
|
||||||
|
body { margin: 0; overflow: hidden; font-family: 'Arial', sans-serif; }
|
||||||
|
.alert-container { position: fixed; bottom: 20px; right: 20px; width: 300px; z-index: 1000; }
|
||||||
|
.alert { background: rgba(0,0,0,0.8); color: white; border-radius: 8px; padding: 10px; margin-bottom: 10px; animation: slideIn 0.5s ease; display: flex; align-items: center; gap: 10px; }
|
||||||
|
.alert img { max-width: 60px; max-height: 60px; border-radius: 50%; }
|
||||||
|
.alert .content { flex: 1; }
|
||||||
|
.alert .title { font-weight: bold; font-size: 1.2em; }
|
||||||
|
.alert .text { font-size: 0.9em; }
|
||||||
|
@keyframes slideIn { from { transform: translateX(100%); opacity: 0; } to { transform: translateX(0); opacity: 1; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="alerts" class="alert-container"></div>
|
||||||
|
<script>
|
||||||
|
const eventSource = new EventSource('/events');
|
||||||
|
eventSource.onmessage = function(e) { showAlert(JSON.parse(e.data)); };
|
||||||
|
function showAlert(ev) {
|
||||||
|
const container = document.getElementById('alerts');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'alert';
|
||||||
|
let imgHtml = ev.image ? '<img src="' + ev.image + '">' : '';
|
||||||
|
div.innerHTML = imgHtml + '<div class="content"><div class="title">' + escapeHtml(ev.title) + '</div><div class="text">' + escapeHtml(ev.text) + '</div></div>';
|
||||||
|
container.appendChild(div);
|
||||||
|
setTimeout(() => div.remove(), ev.duration * 1000);
|
||||||
|
if (ev.sound) new Audio(ev.sound).play().catch(e => console.log(e));
|
||||||
|
}
|
||||||
|
function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>]/g, function(m) { if (m === '&') return '&'; if (m === '<') return '<'; if (m === '>') return '>'; return m; }); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 := `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Chat Overlay</title>
|
||||||
|
<style>
|
||||||
|
body { background-color: {{.BackgroundColor}}; color: {{.TextColor}}; font-family: {{.FontFamily}}; font-size: {{.FontSize}}px; opacity: {{.Opacity}}%; margin: 0; padding: 10px; overflow: hidden; }
|
||||||
|
.message { margin-bottom: 8px; padding: 4px; border-bottom: 1px solid rgba(255,255,255,0.2); animation: fadeIn 0.3s; }
|
||||||
|
.badge { display: inline-block; margin-right: 4px; }
|
||||||
|
.username { font-weight: bold; margin-right: 8px; }
|
||||||
|
.mod { color: #34eb5e; }
|
||||||
|
.vip { color: #e8b92e; }
|
||||||
|
.sub { color: #9147ff; }
|
||||||
|
.time { font-size: 0.8em; color: #aaa; margin-right: 8px; }
|
||||||
|
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="messages"></div>
|
||||||
|
<script>
|
||||||
|
if (window.eventSource) window.eventSource.close();
|
||||||
|
const eventSource = new EventSource('/events');
|
||||||
|
window.eventSource = eventSource;
|
||||||
|
let messageCounter = 0;
|
||||||
|
const maxMessages = {{.MaxMessages}};
|
||||||
|
const messageTimeout = {{.MessageTimeoutSec}} * 1000;
|
||||||
|
let messageQueue = [];
|
||||||
|
function addMessage(msg) {
|
||||||
|
messageCounter++;
|
||||||
|
console.log('#' + messageCounter, msg.username + ':', msg.message);
|
||||||
|
const container = document.getElementById('messages');
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'message';
|
||||||
|
let badgeHtml = '';
|
||||||
|
if (msg.is_mod) badgeHtml += '<span class="badge mod">🎭</span>';
|
||||||
|
if (msg.is_vip) badgeHtml += '<span class="badge vip">👑</span>';
|
||||||
|
if (msg.is_sub) badgeHtml += '<span class="badge sub">✔️</span>';
|
||||||
|
let timeHtml = msg.timestamp ? '<span class="time">' + new Date(msg.timestamp*1000).toLocaleTimeString() + '</span>' : '';
|
||||||
|
div.innerHTML = timeHtml + badgeHtml + '<span class="username">' + escapeHtml(msg.username) + ':</span> ' + escapeHtml(msg.message);
|
||||||
|
container.appendChild(div);
|
||||||
|
messageQueue.push(div);
|
||||||
|
if (messageQueue.length > maxMessages) { const oldest = messageQueue.shift(); oldest.remove(); }
|
||||||
|
if (messageTimeout > 0) { setTimeout(() => { const idx = messageQueue.indexOf(div); if (idx !== -1) { messageQueue.splice(idx, 1); div.remove(); } }, messageTimeout); }
|
||||||
|
container.scrollTop = container.scrollHeight;
|
||||||
|
}
|
||||||
|
eventSource.onmessage = function(e) { addMessage(JSON.parse(e.data)); };
|
||||||
|
eventSource.onerror = function(e) { console.error('SSE error', e); };
|
||||||
|
function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>]/g, function(m) { if (m === '&') return '&'; if (m === '<') return '<'; if (m === '>') return '>'; return m; }); }
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package webservices
|
||||||
|
|
||||||
|
type Service interface {
|
||||||
|
Start() error
|
||||||
|
Stop() error
|
||||||
|
ReloadConfig(config interface{}) error
|
||||||
|
GetPort() int
|
||||||
|
GetType() string
|
||||||
|
IsRunning() bool
|
||||||
|
}
|
||||||
@@ -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"` // добавлено поле
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h2>Настройки нейросетей</h2>
|
||||||
|
<div class="card">
|
||||||
|
<form id="ai-form">
|
||||||
|
<label>Провайдер:</label>
|
||||||
|
<select id="provider" name="provider">
|
||||||
|
<option value="ollama">Ollama (локальная)</option>
|
||||||
|
<option value="chatgpt">ChatGPT (OpenAI)</option>
|
||||||
|
<option value="gigachat">GigaChat (Сбер)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="ollama-fields" class="provider-fields">
|
||||||
|
<label>Endpoint:</label>
|
||||||
|
<input type="text" id="endpoint" name="endpoint" placeholder="http://localhost:11434">
|
||||||
|
<label>Модель:</label>
|
||||||
|
<input type="text" id="model" name="model" placeholder="llama2, mistral, ...">
|
||||||
|
</div>
|
||||||
|
<div id="chatgpt-fields" class="provider-fields" style="display:none;">
|
||||||
|
<label>API Key:</label>
|
||||||
|
<input type="password" id="api_key" name="api_key" placeholder="sk-...">
|
||||||
|
<label>Модель:</label>
|
||||||
|
<input type="text" id="model_gpt" name="model_gpt" placeholder="gpt-3.5-turbo">
|
||||||
|
</div>
|
||||||
|
<div id="gigachat-fields" class="provider-fields" style="display:none;">
|
||||||
|
<label>Client ID:</label>
|
||||||
|
<input type="text" id="client_id" name="client_id" placeholder="ваш client_id">
|
||||||
|
<label>Client Secret:</label>
|
||||||
|
<input type="password" id="client_secret" name="client_secret" placeholder="ваш client_secret">
|
||||||
|
<label>Endpoint (опционально):</label>
|
||||||
|
<input type="text" id="endpoint_giga" name="endpoint_giga" placeholder="https://gigachat.devices.sberbank.ru/api/v1">
|
||||||
|
<label>Модель (опционально):</label>
|
||||||
|
<input type="text" id="model_giga" name="model_giga" placeholder="GigaChat">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>Системный промпт (префикс):</label>
|
||||||
|
<textarea id="system_prompt" name="system_prompt" rows="3">ты в чате твитча, ответь одним предложением.</textarea>
|
||||||
|
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Тестирование</h3>
|
||||||
|
<input type="text" id="test-prompt" placeholder="Введите вопрос для нейросети" style="width: 70%;">
|
||||||
|
<button id="test-btn">Отправить</button>
|
||||||
|
<div id="test-result" class="test-result-block"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const providerSelect = document.getElementById('provider');
|
||||||
|
const ollamaFields = document.getElementById('ollama-fields');
|
||||||
|
const chatgptFields = document.getElementById('chatgpt-fields');
|
||||||
|
const gigachatFields = document.getElementById('gigachat-fields');
|
||||||
|
|
||||||
|
function showProviderFields() {
|
||||||
|
const prov = providerSelect.value;
|
||||||
|
ollamaFields.style.display = 'none';
|
||||||
|
chatgptFields.style.display = 'none';
|
||||||
|
gigachatFields.style.display = 'none';
|
||||||
|
if (prov === 'ollama') ollamaFields.style.display = 'block';
|
||||||
|
else if (prov === 'chatgpt') chatgptFields.style.display = 'block';
|
||||||
|
else if (prov === 'gigachat') gigachatFields.style.display = 'block';
|
||||||
|
}
|
||||||
|
providerSelect.addEventListener('change', showProviderFields);
|
||||||
|
|
||||||
|
async function loadConfig() {
|
||||||
|
const res = await fetch('/api/ai/config');
|
||||||
|
const cfg = await res.json();
|
||||||
|
providerSelect.value = cfg.provider || 'ollama';
|
||||||
|
document.getElementById('endpoint').value = cfg.endpoint || '';
|
||||||
|
document.getElementById('model').value = cfg.model || '';
|
||||||
|
document.getElementById('api_key').value = cfg.api_key || '';
|
||||||
|
document.getElementById('model_gpt').value = cfg.model || '';
|
||||||
|
document.getElementById('client_id').value = cfg.client_id || '';
|
||||||
|
document.getElementById('client_secret').value = cfg.client_secret || '';
|
||||||
|
document.getElementById('endpoint_giga').value = cfg.endpoint || '';
|
||||||
|
document.getElementById('model_giga').value = cfg.model || '';
|
||||||
|
document.getElementById('system_prompt').value = cfg.system_prompt || 'ты в чате твитча, ответь одним предложением.';
|
||||||
|
showProviderFields();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('ai-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const provider = providerSelect.value;
|
||||||
|
let apiKey = '', endpoint = '', model = '', clientId = '', clientSecret = '';
|
||||||
|
if (provider === 'ollama') {
|
||||||
|
endpoint = document.getElementById('endpoint').value;
|
||||||
|
model = document.getElementById('model').value;
|
||||||
|
} else if (provider === 'chatgpt') {
|
||||||
|
apiKey = document.getElementById('api_key').value;
|
||||||
|
model = document.getElementById('model_gpt').value;
|
||||||
|
} else if (provider === 'gigachat') {
|
||||||
|
clientId = document.getElementById('client_id').value;
|
||||||
|
clientSecret = document.getElementById('client_secret').value;
|
||||||
|
endpoint = document.getElementById('endpoint_giga').value;
|
||||||
|
model = document.getElementById('model_giga').value;
|
||||||
|
}
|
||||||
|
const systemPrompt = document.getElementById('system_prompt').value;
|
||||||
|
const body = {
|
||||||
|
provider,
|
||||||
|
api_key: apiKey,
|
||||||
|
endpoint,
|
||||||
|
model,
|
||||||
|
system_prompt: systemPrompt,
|
||||||
|
client_id: clientId,
|
||||||
|
client_secret: clientSecret
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/config', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
alert('Настройки сохранены');
|
||||||
|
await loadConfig();
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
alert('Ошибка сохранения: ' + err);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка соединения: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('test-btn').addEventListener('click', async () => {
|
||||||
|
const prompt = document.getElementById('test-prompt').value;
|
||||||
|
if (!prompt) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/ai/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt })
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('test-result').innerHTML = `<strong>Ответ:</strong> ${escapeHtml(data.answer)}`;
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
document.getElementById('test-result').innerHTML = `<span style="color:red;">Ошибка: ${escapeHtml(err)}</span>`;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
document.getElementById('test-result').innerHTML = `<span style="color:red;">Ошибка соединения: ${err.message}</span>`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/[&<>]/g, function(m) {
|
||||||
|
if (m === '&') return '&';
|
||||||
|
if (m === '<') return '<';
|
||||||
|
if (m === '>') return '>';
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
loadConfig();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes">
|
||||||
|
<title>TTW_Bot</title>
|
||||||
|
<link rel="stylesheet" href="/static/styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<nav>
|
||||||
|
<div>
|
||||||
|
<a href="/">Главная</a>
|
||||||
|
<a href="/platforms">Платформы</a>
|
||||||
|
<a href="/commands">Команды</a>
|
||||||
|
<a href="/events">События</a>
|
||||||
|
<a href="/hotkeys">Горячие клавиши</a>
|
||||||
|
<a href="/webservices">Веб-сервисы</a>
|
||||||
|
<a href="/logs">Логи</a>
|
||||||
|
<a href="/ai">Нейросети</a>
|
||||||
|
<a href="/notifications">Уведомления</a>
|
||||||
|
</div>
|
||||||
|
<div class="theme-switch" id="theme-toggle">🌙 Тёмная тема</div>
|
||||||
|
</nav>
|
||||||
|
<div class="container">
|
||||||
|
{{block "content" .}}{{end}}
|
||||||
|
</div>
|
||||||
|
<footer style="text-align: center; margin-top: 20px; font-size: 0.8em; color: gray;">
|
||||||
|
TTW_Bot версия 11.0.43
|
||||||
|
</footer>
|
||||||
|
<script>
|
||||||
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
|
const currentTheme = localStorage.getItem('theme');
|
||||||
|
if (currentTheme === 'dark') {
|
||||||
|
document.body.classList.add('dark');
|
||||||
|
themeToggle.textContent = '☀️ Светлая тема';
|
||||||
|
}
|
||||||
|
themeToggle.addEventListener('click', () => {
|
||||||
|
document.body.classList.toggle('dark');
|
||||||
|
const isDark = document.body.classList.contains('dark');
|
||||||
|
localStorage.setItem('theme', isDark ? 'dark' : 'light');
|
||||||
|
themeToggle.textContent = isDark ? '☀️ Светлая тема' : '🌙 Тёмная тема';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,434 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h2>Команды чата</h2>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Добавить / редактировать команду</h3>
|
||||||
|
<form id="command-form">
|
||||||
|
<input type="hidden" id="command-id" value="0">
|
||||||
|
<label>Триггер (без !):</label>
|
||||||
|
<input type="text" id="trigger" required placeholder="ping">
|
||||||
|
|
||||||
|
<label>Шаблон ответа:</label>
|
||||||
|
<textarea id="template" rows="5" required placeholder="Pong! <USERNAME/>"></textarea>
|
||||||
|
|
||||||
|
<!-- Панель кнопок для вставки тегов -->
|
||||||
|
<div style="margin: 8px 0; display: flex; flex-wrap: wrap; gap: 6px;">
|
||||||
|
<button type="button" class="tag-btn" data-tag="<USERNAME/>">👤 USERNAME</button>
|
||||||
|
<button type="button" class="tag-btn" data-tag="<ARG/>">📝 ARG</button>
|
||||||
|
<button type="button" class="tag-btn" data-tag="<AI/>">🤖 AI</button>
|
||||||
|
<button type="button" class="tag-btn" data-tag="<RANDOMUSER/>">🎲 RANDOMUSER</button>
|
||||||
|
<button type="button" id="random-btn">🔢 Случайное число</button>
|
||||||
|
<button type="button" id="song-btn">🎵 Вставить звук</button>
|
||||||
|
<button type="button" id="group-btn">📦 Обертка group</button>
|
||||||
|
<button type="button" id="timeout-btn">⏱ Отстранение</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="button" id="test-btn">🧪 Тестировать</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="enabled" checked> Включена
|
||||||
|
</label>
|
||||||
|
<label>Кулдаун (секунды):</label>
|
||||||
|
<input type="number" id="cooldown" value="0" min="0">
|
||||||
|
<label>Права доступа:</label>
|
||||||
|
<select id="permission">
|
||||||
|
<option value="everyone">Все</option>
|
||||||
|
<option value="moderator">Модераторы</option>
|
||||||
|
<option value="subscriber">Подписчики</option>
|
||||||
|
<option value="vip">VIP</option>
|
||||||
|
<option value="broadcaster">Стример</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
<button type="button" id="cancel-edit" style="background: #6c757d;">Отмена</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Список команд</h3>
|
||||||
|
<table style="width:100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Триггер</th><th>Шаблон</th><th>Кулдаун</th><th>Права</th><th>Статус</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="commands-table">
|
||||||
|
<tr><td colspan="6">Загрузка...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для тестирования -->
|
||||||
|
<div id="test-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Результат тестирования</h3>
|
||||||
|
<p><strong>Текст для чата:</strong></p>
|
||||||
|
<pre id="test-result" style="background:#f0f0f0; padding:10px; border-radius:4px; white-space:pre-wrap;"></pre>
|
||||||
|
<p><strong>Звуковые файлы:</strong> <span id="test-sounds"></span></p>
|
||||||
|
<button id="close-modal" style="margin-top:10px;">Закрыть</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для случайного числа -->
|
||||||
|
<div id="random-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Вставить случайное число</h3>
|
||||||
|
<label>Минимум:</label>
|
||||||
|
<input type="number" id="random-min" value="1">
|
||||||
|
<label>Максимум:</label>
|
||||||
|
<input type="number" id="random-max" value="100">
|
||||||
|
<div style="margin-top:15px;">
|
||||||
|
<button id="insert-random-btn">Вставить</button>
|
||||||
|
<button id="cancel-random-btn">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для звука -->
|
||||||
|
<div id="song-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Вставить звук</h3>
|
||||||
|
<label>Выберите звуковой файл:</label>
|
||||||
|
<select id="song-select" style="width:100%;">
|
||||||
|
<option value="">-- нет звуков --</option>
|
||||||
|
</select>
|
||||||
|
<div style="margin-top:15px;">
|
||||||
|
<button id="insert-song-btn">Вставить</button>
|
||||||
|
<button id="cancel-song-btn">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для группы -->
|
||||||
|
<div id="group-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Создать группу вариантов</h3>
|
||||||
|
<div id="group-variants">
|
||||||
|
<div class="group-variant" style="margin-bottom:8px;">
|
||||||
|
<input type="text" class="variant-input" placeholder="Вариант 1" style="width:80%;">
|
||||||
|
</div>
|
||||||
|
<div class="group-variant" style="margin-bottom:8px;">
|
||||||
|
<input type="text" class="variant-input" placeholder="Вариант 2" style="width:80%;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" id="add-variant-btn">+ Добавить вариант</button>
|
||||||
|
<button type="button" id="remove-variant-btn" style="margin-left:10px;">− Удалить последний</button>
|
||||||
|
<div style="margin-top:15px;">
|
||||||
|
<button id="insert-group-btn">Вставить</button>
|
||||||
|
<button id="cancel-group-btn">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для отстранения (timeout) -->
|
||||||
|
<div id="timeout-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3>Отстранение (таймаут)</h3>
|
||||||
|
<label>Количество минут:</label>
|
||||||
|
<input type="number" id="timeout-minutes" value="5" min="1" max="1440">
|
||||||
|
<div style="margin-top:15px;">
|
||||||
|
<button id="insert-timeout-btn">Вставить</button>
|
||||||
|
<button id="cancel-timeout-btn">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let commands = [];
|
||||||
|
let soundList = [];
|
||||||
|
|
||||||
|
async function loadSoundList() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/sounds/list');
|
||||||
|
if (res.ok) {
|
||||||
|
soundList = await res.json();
|
||||||
|
const select = document.getElementById('song-select');
|
||||||
|
select.innerHTML = '<option value="">-- выберите звук --</option>';
|
||||||
|
soundList.forEach(sound => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = sound;
|
||||||
|
option.textContent = sound;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch(e) { console.error('Failed to load sounds', e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertAtCursor(textareaId, text) {
|
||||||
|
const textarea = document.getElementById(textareaId);
|
||||||
|
if (!textarea) return;
|
||||||
|
const start = textarea.selectionStart;
|
||||||
|
const end = textarea.selectionEnd;
|
||||||
|
const value = textarea.value;
|
||||||
|
textarea.value = value.slice(0, start) + text + value.slice(end);
|
||||||
|
textarea.selectionStart = textarea.selectionEnd = start + text.length;
|
||||||
|
textarea.focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Простые теги
|
||||||
|
document.querySelectorAll('.tag-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => {
|
||||||
|
insertAtCursor('template', btn.dataset.tag);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Random
|
||||||
|
document.getElementById('random-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('random-modal').style.display = 'flex';
|
||||||
|
});
|
||||||
|
document.getElementById('insert-random-btn').addEventListener('click', () => {
|
||||||
|
const min = document.getElementById('random-min').value;
|
||||||
|
const max = document.getElementById('random-max').value;
|
||||||
|
const tag = `<random s=${min} e=${max}/>`;
|
||||||
|
insertAtCursor('template', tag);
|
||||||
|
document.getElementById('random-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('cancel-random-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('random-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Song
|
||||||
|
document.getElementById('song-btn').addEventListener('click', async () => {
|
||||||
|
await loadSoundList();
|
||||||
|
document.getElementById('song-modal').style.display = 'flex';
|
||||||
|
});
|
||||||
|
document.getElementById('insert-song-btn').addEventListener('click', () => {
|
||||||
|
const selected = document.getElementById('song-select').value;
|
||||||
|
if (!selected) {
|
||||||
|
alert('Выберите звуковой файл');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tag = `<song f="data/sounds/${selected}"/>`;
|
||||||
|
insertAtCursor('template', tag);
|
||||||
|
document.getElementById('song-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('cancel-song-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('song-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group
|
||||||
|
document.getElementById('group-btn').addEventListener('click', () => {
|
||||||
|
const container = document.getElementById('group-variants');
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="group-variant" style="margin-bottom:8px;">
|
||||||
|
<input type="text" class="variant-input" placeholder="Вариант 1" style="width:80%;">
|
||||||
|
</div>
|
||||||
|
<div class="group-variant" style="margin-bottom:8px;">
|
||||||
|
<input type="text" class="variant-input" placeholder="Вариант 2" style="width:80%;">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.getElementById('group-modal').style.display = 'flex';
|
||||||
|
});
|
||||||
|
document.getElementById('add-variant-btn').addEventListener('click', () => {
|
||||||
|
const container = document.getElementById('group-variants');
|
||||||
|
const newDiv = document.createElement('div');
|
||||||
|
newDiv.className = 'group-variant';
|
||||||
|
newDiv.style.marginBottom = '8px';
|
||||||
|
newDiv.innerHTML = `<input type="text" class="variant-input" placeholder="Новый вариант" style="width:80%;">`;
|
||||||
|
container.appendChild(newDiv);
|
||||||
|
});
|
||||||
|
document.getElementById('remove-variant-btn').addEventListener('click', () => {
|
||||||
|
const container = document.getElementById('group-variants');
|
||||||
|
if (container.children.length > 2) {
|
||||||
|
container.removeChild(container.lastChild);
|
||||||
|
} else {
|
||||||
|
alert('Должно быть хотя бы два варианта');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById('insert-group-btn').addEventListener('click', () => {
|
||||||
|
const inputs = document.querySelectorAll('#group-variants .variant-input');
|
||||||
|
let variants = [];
|
||||||
|
inputs.forEach(inp => {
|
||||||
|
let val = inp.value.trim();
|
||||||
|
if (val !== '') variants.push(val);
|
||||||
|
});
|
||||||
|
if (variants.length < 2) {
|
||||||
|
alert('Введите хотя бы два непустых варианта');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let groupHtml = '<group>\n';
|
||||||
|
variants.forEach(v => {
|
||||||
|
groupHtml += ` <g>${escapeHtmlForGroup(v)}</g>\n`;
|
||||||
|
});
|
||||||
|
groupHtml += '</group>';
|
||||||
|
insertAtCursor('template', groupHtml);
|
||||||
|
document.getElementById('group-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('cancel-group-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('group-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Timeout
|
||||||
|
document.getElementById('timeout-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('timeout-modal').style.display = 'flex';
|
||||||
|
});
|
||||||
|
document.getElementById('insert-timeout-btn').addEventListener('click', () => {
|
||||||
|
const minutes = document.getElementById('timeout-minutes').value;
|
||||||
|
const tag = `<timeout minutes="${minutes}"/>`;
|
||||||
|
insertAtCursor('template', tag);
|
||||||
|
document.getElementById('timeout-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
document.getElementById('cancel-timeout-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('timeout-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
function escapeHtmlForGroup(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/[&<>]/g, function(m) {
|
||||||
|
if (m === '&') return '&';
|
||||||
|
if (m === '<') return '<';
|
||||||
|
if (m === '>') return '>';
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрытие модалок по клику вне области
|
||||||
|
window.addEventListener('click', (e) => {
|
||||||
|
const modals = ['random-modal', 'song-modal', 'group-modal', 'test-modal', 'timeout-modal'];
|
||||||
|
modals.forEach(id => {
|
||||||
|
const modal = document.getElementById(id);
|
||||||
|
if (e.target === modal) modal.style.display = 'none';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Остальной код команд (без изменений) ---
|
||||||
|
async function loadCommands() {
|
||||||
|
const res = await fetch('/api/commands');
|
||||||
|
commands = await res.json();
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = document.getElementById('commands-table');
|
||||||
|
if (!commands.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6">Нет команд. Добавьте первую!</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = commands.map(cmd => `
|
||||||
|
<tr>
|
||||||
|
<td>!${escapeHtml(cmd.Trigger)}</td>
|
||||||
|
<td style="max-width: 300px; overflow-x: auto;">${escapeHtml(cmd.Template)}</td>
|
||||||
|
<td>${cmd.CooldownSec}</td>
|
||||||
|
<td>${cmd.Permission}</td>
|
||||||
|
<td>${cmd.Enabled ? '✅ Вкл' : '❌ Выкл'}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick="editCommand(${cmd.ID})">✏️</button>
|
||||||
|
<button onclick="deleteCommand(${cmd.ID})">🗑️</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/[&<>]/g, function(m) {
|
||||||
|
if (m === '&') return '&';
|
||||||
|
if (m === '<') return '<';
|
||||||
|
if (m === '>') return '>';
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function editCommand(id) {
|
||||||
|
const cmd = commands.find(c => c.ID === id);
|
||||||
|
if (!cmd) return;
|
||||||
|
document.getElementById('command-id').value = cmd.ID;
|
||||||
|
document.getElementById('trigger').value = cmd.Trigger;
|
||||||
|
document.getElementById('template').value = cmd.Template;
|
||||||
|
document.getElementById('enabled').checked = cmd.Enabled;
|
||||||
|
document.getElementById('cooldown').value = cmd.CooldownSec;
|
||||||
|
document.getElementById('permission').value = cmd.Permission;
|
||||||
|
document.querySelector('.card').scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteCommand(id) {
|
||||||
|
if (!confirm('Удалить команду?')) return;
|
||||||
|
await fetch(`/api/commands?id=${id}`, { method: 'DELETE' });
|
||||||
|
loadCommands();
|
||||||
|
if (document.getElementById('command-id').value == id) resetForm();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetForm() {
|
||||||
|
document.getElementById('command-id').value = 0;
|
||||||
|
document.getElementById('trigger').value = '';
|
||||||
|
document.getElementById('template').value = '';
|
||||||
|
document.getElementById('enabled').checked = true;
|
||||||
|
document.getElementById('cooldown').value = 0;
|
||||||
|
document.getElementById('permission').value = 'everyone';
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('cancel-edit').addEventListener('click', resetForm);
|
||||||
|
|
||||||
|
document.getElementById('command-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = parseInt(document.getElementById('command-id').value);
|
||||||
|
const trigger = document.getElementById('trigger').value.trim();
|
||||||
|
const template = document.getElementById('template').value;
|
||||||
|
const enabled = document.getElementById('enabled').checked;
|
||||||
|
const cooldown = parseInt(document.getElementById('cooldown').value);
|
||||||
|
const permission = document.getElementById('permission').value;
|
||||||
|
|
||||||
|
if (!trigger || !template) {
|
||||||
|
alert('Заполните триггер и шаблон');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const method = id === 0 ? 'POST' : 'PUT';
|
||||||
|
const url = '/api/commands';
|
||||||
|
const body = JSON.stringify({ ID: id, Trigger: trigger, Template: template, Enabled: enabled, CooldownSec: cooldown, Permission: permission });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body });
|
||||||
|
if (res.ok) {
|
||||||
|
loadCommands();
|
||||||
|
resetForm();
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
alert('Ошибка: ' + err);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка соединения: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('test-btn').addEventListener('click', async () => {
|
||||||
|
const template = document.getElementById('template').value;
|
||||||
|
if (!template) {
|
||||||
|
alert('Введите шаблон для тестирования');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const username = prompt('Введите имя пользователя для подстановки (без @):', 'TestUser');
|
||||||
|
if (!username) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/commands/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ template, username })
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.text();
|
||||||
|
alert('Ошибка тестирования: ' + err);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('test-result').textContent = data.result;
|
||||||
|
const soundsSpan = document.getElementById('test-sounds');
|
||||||
|
if (data.soundFiles && data.soundFiles.length) {
|
||||||
|
soundsSpan.innerHTML = data.soundFiles.join('<br>');
|
||||||
|
} else {
|
||||||
|
soundsSpan.textContent = 'нет';
|
||||||
|
}
|
||||||
|
document.getElementById('test-modal').style.display = 'flex';
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка: ' + err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('close-modal').addEventListener('click', () => {
|
||||||
|
document.getElementById('test-modal').style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
loadCommands();
|
||||||
|
loadSoundList();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h2>Пользователи чата</h2>
|
||||||
|
<div style="overflow-x: auto;">
|
||||||
|
<table id="users-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Пользователь</th>
|
||||||
|
<th>Сообщений</th>
|
||||||
|
<th>Последняя активность</th>
|
||||||
|
<th>Модератор</th>
|
||||||
|
<th>VIP</th>
|
||||||
|
<th>Подписчик</th>
|
||||||
|
<th>Действия</th>
|
||||||
|
<th>Отмечать</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let users = [];
|
||||||
|
let refreshInterval = null;
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users');
|
||||||
|
users = await res.json();
|
||||||
|
renderTable();
|
||||||
|
} catch(err) {
|
||||||
|
console.error('Failed to load users', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = document.querySelector('#users-table tbody');
|
||||||
|
if (!users.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="8">Нет активных пользователей</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = users.map(user => `
|
||||||
|
<tr data-username="${escapeHtml(user.username)}">
|
||||||
|
<td>${escapeHtml(user.username)}</td>
|
||||||
|
<td>${user.message_count}</td>
|
||||||
|
<td>${formatRelativeTime(user.last_active)}</td>
|
||||||
|
<td><input type="checkbox" class="mod-checkbox" ${user.is_mod ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
|
||||||
|
<td><input type="checkbox" class="vip-checkbox" ${user.is_vip ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
|
||||||
|
<td><input type="checkbox" disabled ${user.is_subscriber ? 'checked' : ''}></td>
|
||||||
|
<td>
|
||||||
|
<button class="warn-btn" data-user="${escapeHtml(user.username)}">Предупредить</button>
|
||||||
|
<button class="timeout10s-btn" data-user="${escapeHtml(user.username)}">10 сек</button>
|
||||||
|
<button class="timeout10m-btn" data-user="${escapeHtml(user.username)}">10 мин</button>
|
||||||
|
<button class="ban-btn" data-user="${escapeHtml(user.username)}">Забанить</button>
|
||||||
|
<button class="unban-btn" data-user="${escapeHtml(user.username)}">Разбанить</button>
|
||||||
|
</td>
|
||||||
|
<td><input type="checkbox" class="mark-checkbox" ${user.is_marked ? 'checked' : ''} data-username="${escapeHtml(user.username)}"></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
document.querySelectorAll('.mod-checkbox').forEach(cb => {
|
||||||
|
cb.addEventListener('change', (e) => toggleMod(cb.dataset.username, cb.checked));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.vip-checkbox').forEach(cb => {
|
||||||
|
cb.addEventListener('change', (e) => toggleVip(cb.dataset.username, cb.checked));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.warn-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => userAction(btn.dataset.user, 'warn'));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.timeout10s-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => userAction(btn.dataset.user, 'timeout10sec'));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.timeout10m-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => userAction(btn.dataset.user, 'timeout10min'));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.ban-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => userAction(btn.dataset.user, 'ban'));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.unban-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => userAction(btn.dataset.user, 'unban'));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.mark-checkbox').forEach(cb => {
|
||||||
|
cb.addEventListener('change', (e) => toggleMark(cb.dataset.username, cb.checked));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleMod(username, isMod) {
|
||||||
|
const action = isMod ? 'set_mod' : 'unset_mod';
|
||||||
|
await userAction(username, action);
|
||||||
|
}
|
||||||
|
async function toggleVip(username, isVip) {
|
||||||
|
const action = isVip ? 'set_vip' : 'unset_vip';
|
||||||
|
await userAction(username, action);
|
||||||
|
}
|
||||||
|
async function toggleMark(username, isMarked) {
|
||||||
|
await userAction(username, 'toggle_mark');
|
||||||
|
}
|
||||||
|
async function userAction(username, action) {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/users/action', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({ username, action, platform: 'twitch' })
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(await res.text());
|
||||||
|
await loadUsers();
|
||||||
|
} catch(err) {
|
||||||
|
alert('Ошибка: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/[&<>]/g, function(m) {
|
||||||
|
if (m === '&') return '&';
|
||||||
|
if (m === '<') return '<';
|
||||||
|
if (m === '>') return '>';
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelativeTime(timestamp) {
|
||||||
|
if (!timestamp) return 'никогда';
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
const now = new Date();
|
||||||
|
const seconds = Math.floor((now - date) / 1000);
|
||||||
|
if (seconds < 60) return 'только что';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes} мин. назад`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours} ч. назад`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
if (days < 30) return `${days} дн. назад`;
|
||||||
|
const months = Math.floor(days / 30);
|
||||||
|
if (months < 12) return `${months} мес. назад`;
|
||||||
|
const years = Math.floor(months / 12);
|
||||||
|
return `${years} г. назад`;
|
||||||
|
}
|
||||||
|
|
||||||
|
loadUsers();
|
||||||
|
refreshInterval = setInterval(loadUsers, 3000);
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h2>События Twitch</h2>
|
||||||
|
<p>Настройте цепочку действий, которые будут выполняться при наступлении события (подписка, рейд, награда и т.д.)</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<label for="event-select">Выберите событие:</label>
|
||||||
|
<select id="event-select">
|
||||||
|
<option value="follow">Подписка на канал (follow)</option>
|
||||||
|
<option value="subscribe">Подписка (subscribe)</option>
|
||||||
|
<option value="gift_sub">Подарочная подписка (gift_sub)</option>
|
||||||
|
<option value="raid">Рейд (raid)</option>
|
||||||
|
<option value="reward_redemption">Награда за баллы (reward_redemption)</option>
|
||||||
|
</select>
|
||||||
|
<button id="load-actions">Загрузить действия</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card" id="actions-editor" style="display: none;">
|
||||||
|
<h3>Цепочка действий</h3>
|
||||||
|
<div id="actions-list"></div>
|
||||||
|
<button id="add-action">➕ Добавить действие</button>
|
||||||
|
<button id="save-actions">💾 Сохранить</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для создания/редактирования действия -->
|
||||||
|
<div id="action-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3 id="modal-title">Действие</h3>
|
||||||
|
<form id="action-form">
|
||||||
|
<input type="hidden" id="action-index" value="-1">
|
||||||
|
<label>Тип действия:</label>
|
||||||
|
<select id="action-type">
|
||||||
|
<option value="send_message">Отправить сообщение</option>
|
||||||
|
<option value="play_sound">Воспроизвести звук</option>
|
||||||
|
<option value="press_hotkey">Нажать горячую клавишу</option>
|
||||||
|
<option value="http_request">HTTP запрос (GET)</option>
|
||||||
|
<option value="run_program">Запустить программу</option>
|
||||||
|
<option value="send_alert">Отправить уведомление (alert)</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div id="fields-container"></div>
|
||||||
|
|
||||||
|
<div style="margin-top: 15px;">
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
<button type="button" id="close-action-modal">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentPlatform = "twitch";
|
||||||
|
let currentEvent = "";
|
||||||
|
let actions = [];
|
||||||
|
let editingIndex = -1;
|
||||||
|
|
||||||
|
// Списки для выпадающих полей
|
||||||
|
let soundFiles = [];
|
||||||
|
let imageFiles = [];
|
||||||
|
|
||||||
|
const eventSelect = document.getElementById('event-select');
|
||||||
|
const loadBtn = document.getElementById('load-actions');
|
||||||
|
const actionsDiv = document.getElementById('actions-list');
|
||||||
|
const editorDiv = document.getElementById('actions-editor');
|
||||||
|
const addBtn = document.getElementById('add-action');
|
||||||
|
const saveBtn = document.getElementById('save-actions');
|
||||||
|
|
||||||
|
const actionModal = document.getElementById('action-modal');
|
||||||
|
const actionTypeSelect = document.getElementById('action-type');
|
||||||
|
const fieldsContainer = document.getElementById('fields-container');
|
||||||
|
const actionForm = document.getElementById('action-form');
|
||||||
|
const actionIndexInput = document.getElementById('action-index');
|
||||||
|
const closeModalBtn = document.getElementById('close-action-modal');
|
||||||
|
|
||||||
|
// Загрузка списков звуков и изображений
|
||||||
|
async function loadSoundList() {
|
||||||
|
const res = await fetch('/api/sounds/list');
|
||||||
|
if (res.ok) {
|
||||||
|
soundFiles = await res.json();
|
||||||
|
} else {
|
||||||
|
soundFiles = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadImageList() {
|
||||||
|
const res = await fetch('/api/images/list');
|
||||||
|
if (res.ok) {
|
||||||
|
imageFiles = await res.json();
|
||||||
|
} else {
|
||||||
|
imageFiles = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция отображения полей в зависимости от типа
|
||||||
|
function renderFieldsForType(type, data = {}) {
|
||||||
|
let html = '';
|
||||||
|
switch (type) {
|
||||||
|
case 'send_message':
|
||||||
|
html = `<label>Текст сообщения:</label><textarea id="action-text" rows="3" style="width:100%;">${escapeHtml(data.text || '')}</textarea>
|
||||||
|
<small>Доступны плейсхолдеры: {{.username}}, {{.reward_title}}, {{.tier}}, {{.viewers}} и др.</small>`;
|
||||||
|
break;
|
||||||
|
case 'play_sound':
|
||||||
|
html = `<label>Звуковой файл:</label>
|
||||||
|
<select id="action-sound-file" style="width:100%;">
|
||||||
|
<option value="">— без звука —</option>
|
||||||
|
${soundFiles.map(f => `<option value="data/sounds/${f}" ${data.sound_file === `data/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
|
||||||
|
</select>`;
|
||||||
|
break;
|
||||||
|
case 'press_hotkey':
|
||||||
|
html = `<label>Комбинация клавиш:</label><input type="text" id="action-keys" style="width:100%;" value="${escapeHtml(data.keys || '')}" placeholder="CTRL+ALT+Q">
|
||||||
|
<small>Поддерживаемые модификаторы: CTRL, ALT, SHIFT, WIN. Основные клавиши: A-Z, 0-9, F1-F12, SPACE, ENTER и др.</small>`;
|
||||||
|
break;
|
||||||
|
case 'http_request':
|
||||||
|
html = `<label>URL (GET):</label><input type="text" id="action-url" style="width:100%;" value="${escapeHtml(data.url || '')}">`;
|
||||||
|
break;
|
||||||
|
case 'run_program':
|
||||||
|
html = `<label>Полный путь к исполняемому файлу:</label><input type="text" id="action-executable" style="width:100%;" value="${escapeHtml(data.executable || '')}">
|
||||||
|
<label>Аргументы (через пробел):</label><input type="text" id="action-args" style="width:100%;" value="${escapeHtml(data.args || '')}">`;
|
||||||
|
break;
|
||||||
|
case 'send_alert':
|
||||||
|
html = `<label>Заголовок:</label><input type="text" id="action-title" style="width:100%;" value="${escapeHtml(data.title || '')}">
|
||||||
|
<label>Текст:</label><textarea id="action-alert-text" rows="2" style="width:100%;">${escapeHtml(data.alert_text || '')}</textarea>
|
||||||
|
<label>Изображение:</label>
|
||||||
|
<select id="action-image" style="width:100%;">
|
||||||
|
<option value="">— без изображения —</option>
|
||||||
|
${imageFiles.map(f => `<option value="/static/${f}" ${data.image === `/static/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<label>Звук:</label>
|
||||||
|
<select id="action-sound" style="width:100%;">
|
||||||
|
<option value="">— без звука —</option>
|
||||||
|
${soundFiles.map(f => `<option value="/sounds/${f}" ${data.sound_file === `/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
<label>Длительность (сек):</label><input type="number" id="action-duration" value="${data.duration || 5}" min="1" max="30">
|
||||||
|
<label>Целевой веб-сервис (ID):</label><input type="number" id="action-target-id" value="${data.target_web_service_id || 0}" min="0">
|
||||||
|
<small>0 = все alert-сервисы. Узнать ID можно на странице "Веб-сервисы".</small>`;
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
html = '<p>Неизвестный тип действия</p>';
|
||||||
|
}
|
||||||
|
fieldsContainer.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Открыть модалку для создания/редактирования
|
||||||
|
async function openActionModal(index) {
|
||||||
|
editingIndex = index;
|
||||||
|
actionIndexInput.value = index;
|
||||||
|
let data = {};
|
||||||
|
if (index >= 0 && index < actions.length) {
|
||||||
|
data = actions[index];
|
||||||
|
actionTypeSelect.value = data.type;
|
||||||
|
} else {
|
||||||
|
actionTypeSelect.value = 'send_message';
|
||||||
|
}
|
||||||
|
// Загружаем списки, если ещё не загружены
|
||||||
|
if (soundFiles.length === 0) await loadSoundList();
|
||||||
|
if (imageFiles.length === 0) await loadImageList();
|
||||||
|
renderFieldsForType(actionTypeSelect.value, data);
|
||||||
|
actionModal.style.display = 'flex';
|
||||||
|
document.getElementById('modal-title').innerText = index >= 0 ? 'Редактировать действие' : 'Новое действие';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Собрать данные из формы
|
||||||
|
function collectActionData() {
|
||||||
|
const type = actionTypeSelect.value;
|
||||||
|
const base = { type: type };
|
||||||
|
switch (type) {
|
||||||
|
case 'send_message':
|
||||||
|
base.text = document.getElementById('action-text')?.value || '';
|
||||||
|
break;
|
||||||
|
case 'play_sound':
|
||||||
|
base.sound_file = document.getElementById('action-sound-file')?.value || '';
|
||||||
|
break;
|
||||||
|
case 'press_hotkey':
|
||||||
|
base.keys = document.getElementById('action-keys')?.value || '';
|
||||||
|
break;
|
||||||
|
case 'http_request':
|
||||||
|
base.url = document.getElementById('action-url')?.value || '';
|
||||||
|
break;
|
||||||
|
case 'run_program':
|
||||||
|
base.executable = document.getElementById('action-executable')?.value || '';
|
||||||
|
base.args = document.getElementById('action-args')?.value || '';
|
||||||
|
break;
|
||||||
|
case 'send_alert':
|
||||||
|
base.title = document.getElementById('action-title')?.value || '';
|
||||||
|
base.alert_text = document.getElementById('action-alert-text')?.value || '';
|
||||||
|
base.image = document.getElementById('action-image')?.value || '';
|
||||||
|
base.sound_file = document.getElementById('action-sound')?.value || '';
|
||||||
|
base.duration = parseInt(document.getElementById('action-duration')?.value) || 5;
|
||||||
|
base.target_web_service_id = parseInt(document.getElementById('action-target-id')?.value) || 0;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отобразить список действий
|
||||||
|
function renderActions() {
|
||||||
|
actionsDiv.innerHTML = '';
|
||||||
|
if (actions.length === 0) {
|
||||||
|
actionsDiv.innerHTML = '<p>Нет действий. Нажмите "Добавить действие".</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
actions.forEach((act, idx) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'action-item';
|
||||||
|
let typeName = '';
|
||||||
|
switch (act.type) {
|
||||||
|
case 'send_message': typeName = '📨 Отправить сообщение'; break;
|
||||||
|
case 'play_sound': typeName = '🔊 Воспроизвести звук'; break;
|
||||||
|
case 'press_hotkey': typeName = '⌨️ Нажать клавиши'; break;
|
||||||
|
case 'http_request': typeName = '🌐 HTTP запрос'; break;
|
||||||
|
case 'run_program': typeName = '⚙️ Запустить программу'; break;
|
||||||
|
case 'send_alert': typeName = '🔔 Уведомление (alert)'; break;
|
||||||
|
default: typeName = act.type;
|
||||||
|
}
|
||||||
|
let summary = '';
|
||||||
|
if (act.type === 'send_message') summary = act.text;
|
||||||
|
else if (act.type === 'play_sound') summary = act.sound_file;
|
||||||
|
else if (act.type === 'press_hotkey') summary = act.keys;
|
||||||
|
else if (act.type === 'http_request') summary = act.url;
|
||||||
|
else if (act.type === 'run_program') summary = act.executable + (act.args ? ' ' + act.args : '');
|
||||||
|
else if (act.type === 'send_alert') summary = act.title + ' / ' + act.alert_text;
|
||||||
|
div.innerHTML = `
|
||||||
|
<strong>${typeName}</strong><br>
|
||||||
|
<small>${escapeHtml(summary)}</small><br>
|
||||||
|
<button class="edit-action" data-idx="${idx}">✏️ Редактировать</button>
|
||||||
|
<button class="remove-action" data-idx="${idx}">🗑️ Удалить</button>
|
||||||
|
`;
|
||||||
|
actionsDiv.appendChild(div);
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.edit-action').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => openActionModal(parseInt(btn.dataset.idx)));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.remove-action').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
const idx = parseInt(btn.dataset.idx);
|
||||||
|
actions.splice(idx, 1);
|
||||||
|
renderActions();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка действий с сервера
|
||||||
|
async function loadEventActions() {
|
||||||
|
currentEvent = eventSelect.value;
|
||||||
|
const res = await fetch(`/api/events?platform=${currentPlatform}&event=${currentEvent}`);
|
||||||
|
if (res.ok) {
|
||||||
|
actions = await res.json();
|
||||||
|
if (!Array.isArray(actions)) actions = [];
|
||||||
|
renderActions();
|
||||||
|
editorDiv.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
alert('Ошибка загрузки действий');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сохранение действий
|
||||||
|
async function saveEventActions() {
|
||||||
|
const res = await fetch(`/api/events?platform=${currentPlatform}&event=${currentEvent}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(actions)
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
alert('Действия сохранены!');
|
||||||
|
} else {
|
||||||
|
alert('Ошибка сохранения');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчики UI
|
||||||
|
loadBtn.addEventListener('click', loadEventActions);
|
||||||
|
addBtn.addEventListener('click', () => openActionModal(-1));
|
||||||
|
saveBtn.addEventListener('click', saveEventActions);
|
||||||
|
|
||||||
|
actionForm.addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const newAction = collectActionData();
|
||||||
|
if (editingIndex >= 0 && editingIndex < actions.length) {
|
||||||
|
actions[editingIndex] = newAction;
|
||||||
|
} else {
|
||||||
|
actions.push(newAction);
|
||||||
|
}
|
||||||
|
renderActions();
|
||||||
|
actionModal.style.display = 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
actionTypeSelect.addEventListener('change', () => {
|
||||||
|
const data = editingIndex >= 0 ? actions[editingIndex] : {};
|
||||||
|
renderFieldsForType(actionTypeSelect.value, data);
|
||||||
|
});
|
||||||
|
|
||||||
|
closeModalBtn.addEventListener('click', () => actionModal.style.display = 'none');
|
||||||
|
window.addEventListener('click', (e) => { if (e.target === actionModal) actionModal.style.display = 'none'; });
|
||||||
|
|
||||||
|
function escapeHtml(str) { if (!str) return ''; return str.replace(/[&<>]/g, function(m) { if (m === '&') return '&'; if (m === '<') return '<'; if (m === '>') return '>'; return m; }); }
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h2>Горячие клавиши по донатам</h2>
|
||||||
|
<p>Правила эмуляции нажатий клавиш при донатах.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Настройка эмуляции горячих клавиш</h3>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="hotkey-enable"> Включить эмуляцию горячих клавиш
|
||||||
|
</label>
|
||||||
|
<p class="help">При включении бот сможет имитировать нажатия клавиш (например, для управления OBS через донаты).</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
async function loadHotkeySetting() {
|
||||||
|
const res = await fetch('/api/settings/hotkey');
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('hotkey-enable').checked = data.enabled;
|
||||||
|
}
|
||||||
|
document.getElementById('hotkey-enable').addEventListener('change', async (e) => {
|
||||||
|
await fetch('/api/settings/hotkey', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: e.target.checked })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
loadHotkeySetting();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h2>Логи бота в реальном времени</h2>
|
||||||
|
<div style="margin-bottom: 10px;">
|
||||||
|
<button id="save-logs-btn">💾 Сохранить логи в файл</button>
|
||||||
|
</div>
|
||||||
|
<div id="log-container">
|
||||||
|
<div>Подключение к потоку логов...</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
document.getElementById('save-logs-btn').addEventListener('click', () => {
|
||||||
|
window.location.href = '/api/logs/download';
|
||||||
|
});
|
||||||
|
const logContainer = document.getElementById('log-container');
|
||||||
|
let eventSource = null;
|
||||||
|
|
||||||
|
function addLogEntry(entry) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
const time = new Date(entry.time).toLocaleTimeString();
|
||||||
|
let levelClass = '';
|
||||||
|
switch (entry.level) {
|
||||||
|
case 'INFO': levelClass = 'color: #4ec9b0;'; break;
|
||||||
|
case 'WARN': levelClass = 'color: #dcdcaa;'; break;
|
||||||
|
case 'ERROR': levelClass = 'color: #f48771;'; break;
|
||||||
|
case 'FATAL': levelClass = 'color: #ff0000; font-weight: bold;'; break;
|
||||||
|
default: levelClass = '';
|
||||||
|
}
|
||||||
|
div.innerHTML = `<span style="color: #888;">[${time}]</span> <span style="${levelClass}">[${entry.level}]</span> ${escapeHtml(entry.message)}`;
|
||||||
|
logContainer.appendChild(div);
|
||||||
|
logContainer.scrollTop = logContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/[&<>]/g, function(m) {
|
||||||
|
if (m === '&') return '&';
|
||||||
|
if (m === '<') return '<';
|
||||||
|
if (m === '>') return '>';
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectLogStream() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
}
|
||||||
|
eventSource = new EventSource('/api/logs/stream');
|
||||||
|
eventSource.onmessage = function(event) {
|
||||||
|
const entry = JSON.parse(event.data);
|
||||||
|
addLogEntry(entry);
|
||||||
|
};
|
||||||
|
eventSource.onerror = function() {
|
||||||
|
logContainer.innerHTML += '<div style="color: red;">⚠️ Потеря соединения, переподключение через 3 секунды...</div>';
|
||||||
|
eventSource.close();
|
||||||
|
setTimeout(connectLogStream, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
connectLogStream();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h2>Звуковые уведомления</h2>
|
||||||
|
<div class="card">
|
||||||
|
<h3>События чата и Twitch</h3>
|
||||||
|
<table id="notif-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>Событие</th><th>Звуковой файл</th><th>Громкость</th><th>Вкл.</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
<button id="add-defaults">➕ Добавить события по умолчанию</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Управление звуковыми файлами</h3>
|
||||||
|
<input type="file" id="sound-upload" accept=".mp3,.wav">
|
||||||
|
<button id="upload-btn">Загрузить</button>
|
||||||
|
<ul id="sound-list"></ul>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h3>Общие настройки звуков</h3>
|
||||||
|
<label>
|
||||||
|
<input type="checkbox" id="duplicate-sounds-checkbox"> Дублировать звуки из команд в локальное воспроизведение
|
||||||
|
</label>
|
||||||
|
<p class="help">При включении, звуки, отправляемые в веб-сервисы оповещений, также будут проигрываться на компьютере стримера.</p>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
let settings = [];
|
||||||
|
let soundFiles = [];
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
const res = await fetch('/api/notifications');
|
||||||
|
settings = await res.json();
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadSounds() {
|
||||||
|
const res = await fetch('/api/sounds');
|
||||||
|
soundFiles = await res.json();
|
||||||
|
const list = document.getElementById('sound-list');
|
||||||
|
if (soundFiles.length === 0) {
|
||||||
|
list.innerHTML = '<li>Нет загруженных звуков. Загрузите MP3 или WAV.</li>';
|
||||||
|
} else {
|
||||||
|
list.innerHTML = soundFiles.map(f => `<li>${escapeHtml(f)} <button class="delete-sound" data-file="${f}">🗑️</button></li>`).join('');
|
||||||
|
document.querySelectorAll('.delete-sound').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => deleteSound(btn.dataset.file));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Обновляем выпадающие списки в таблице
|
||||||
|
renderTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable() {
|
||||||
|
const tbody = document.querySelector('#notif-table tbody');
|
||||||
|
if (!settings.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">Нет настроек. Нажмите "Добавить события по умолчанию".</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = settings.map(s => `
|
||||||
|
<tr data-event="${s.event_name}">
|
||||||
|
<td>${escapeHtml(s.event_name)}</td>
|
||||||
|
<td>
|
||||||
|
<select class="sound-select" data-event="${s.event_name}">
|
||||||
|
<option value="">— без звука —</option>
|
||||||
|
${soundFiles.map(f => `<option value="data/sounds/${f}" ${s.sound_file === `data/sounds/${f}` ? 'selected' : ''}>${f}</option>`).join('')}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<input type="range" class="volume-slider" min="0" max="100" value="${s.volume}" data-event="${s.event_name}">
|
||||||
|
<span class="vol-value">${s.volume}</span>%
|
||||||
|
</td>
|
||||||
|
<td><input type="checkbox" class="enable-checkbox" ${s.enabled ? 'checked' : ''} data-event="${s.event_name}"></td>
|
||||||
|
<td><button class="test-btn" data-event="${s.event_name}">🔊 Тест</button></td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
// Привязываем обработчики
|
||||||
|
document.querySelectorAll('.sound-select').forEach(sel => {
|
||||||
|
sel.addEventListener('change', (e) => updateSetting(e.target.dataset.event, 'sound_file', sel.value));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.volume-slider').forEach(slider => {
|
||||||
|
slider.addEventListener('input', (e) => {
|
||||||
|
const val = e.target.value;
|
||||||
|
const span = e.target.parentElement.querySelector('.vol-value');
|
||||||
|
span.innerText = val;
|
||||||
|
updateSetting(e.target.dataset.event, 'volume', parseInt(val));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.enable-checkbox').forEach(cb => {
|
||||||
|
cb.addEventListener('change', (e) => updateSetting(e.target.dataset.event, 'enabled', cb.checked));
|
||||||
|
});
|
||||||
|
document.querySelectorAll('.test-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', () => testSound(btn.dataset.event));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateSetting(eventName, field, value) {
|
||||||
|
const setting = settings.find(s => s.event_name === eventName);
|
||||||
|
if (!setting) return;
|
||||||
|
setting[field] = value;
|
||||||
|
const res = await fetch('/api/notifications', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(setting)
|
||||||
|
});
|
||||||
|
if (!res.ok) alert('Ошибка сохранения');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testSound(eventName) {
|
||||||
|
await fetch(`/api/notifications/test?event=${encodeURIComponent(eventName)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteSound(filename) {
|
||||||
|
if (!confirm(`Удалить ${filename}?`)) return;
|
||||||
|
const res = await fetch(`/api/sounds?file=${encodeURIComponent(filename)}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) {
|
||||||
|
await loadSounds();
|
||||||
|
await loadSettings();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка удаления');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('upload-btn').onclick = async () => {
|
||||||
|
const fileInput = document.getElementById('sound-upload');
|
||||||
|
if (!fileInput.files.length) return;
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('sound', fileInput.files[0]);
|
||||||
|
const res = await fetch('/api/sounds/upload', { method: 'POST', body: formData });
|
||||||
|
if (res.ok) {
|
||||||
|
await loadSounds();
|
||||||
|
await loadSettings();
|
||||||
|
fileInput.value = '';
|
||||||
|
} else {
|
||||||
|
alert('Ошибка загрузки');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('add-defaults').onclick = async () => {
|
||||||
|
const res = await fetch('/api/notifications/defaults', { method: 'POST' });
|
||||||
|
if (res.ok) {
|
||||||
|
await loadSettings();
|
||||||
|
await loadSounds();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка добавления событий по умолчанию');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
function escapeHtml(str) {
|
||||||
|
if (!str) return '';
|
||||||
|
return str.replace(/[&<>]/g, function(m) {
|
||||||
|
if (m === '&') return '&';
|
||||||
|
if (m === '<') return '<';
|
||||||
|
if (m === '>') return '>';
|
||||||
|
return m;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
async function loadDuplicateSetting() {
|
||||||
|
const res = await fetch('/api/settings/duplicate_sounds');
|
||||||
|
const data = await res.json();
|
||||||
|
document.getElementById('duplicate-sounds-checkbox').checked = data.enabled;
|
||||||
|
}
|
||||||
|
document.getElementById('duplicate-sounds-checkbox').addEventListener('change', async (e) => {
|
||||||
|
await fetch('/api/settings/duplicate_sounds', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ enabled: e.target.checked })
|
||||||
|
});
|
||||||
|
});
|
||||||
|
loadDuplicateSetting();
|
||||||
|
loadSettings();
|
||||||
|
loadSounds();
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<div class="card">
|
||||||
|
<h3>Twitch</h3>
|
||||||
|
<div>
|
||||||
|
<p>Статус бота: <span id="twitch-status" class="status">Загрузка...</span></p>
|
||||||
|
<p>Имя канала: <span id="twitch-channel"></span></p>
|
||||||
|
</div>
|
||||||
|
<div style="margin: 10px 0;">
|
||||||
|
<button id="auth-user-btn">🔐 Авторизовать стримера</button>
|
||||||
|
<button id="auth-bot-btn">🤖 Авторизовать бота</button>
|
||||||
|
</div>
|
||||||
|
<div id="twitch-tokens-info"></div>
|
||||||
|
<div id="token-expiry-info" style="margin-top: 10px; font-size: 0.9em;">
|
||||||
|
<p>🕒 Срок действия токенов:</p>
|
||||||
|
<p>👤 Стример: <span id="user-expiry">—</span></p>
|
||||||
|
<p>🤖 Бот: <span id="bot-expiry">—</span></p>
|
||||||
|
</div>
|
||||||
|
<!-- Блок для отображения ссылки авторизации БОТА -->
|
||||||
|
<div id="auth-link-container" style="margin-top: 15px; display: none;">
|
||||||
|
<label>Скопируйте ссылку и откройте её в браузере, где залогинен аккаунт БОТА:</label>
|
||||||
|
<div style="display: flex; gap: 10px; margin-top: 5px;">
|
||||||
|
<input type="text" id="auth-link" readonly style="flex: 1; padding: 8px; font-family: monospace;">
|
||||||
|
<button id="copy-link-btn">📋 Копировать</button>
|
||||||
|
</div>
|
||||||
|
<p id="auth-wait-message" style="color: #666; margin-top: 10px;">Ожидание подтверждения авторизации...</p>
|
||||||
|
</div>
|
||||||
|
<div id="notification" style="position: fixed; top: 20px; right: 20px; z-index: 1000; background: #333; color: white; padding: 10px 20px; border-radius: 5px; display: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let pollInterval = null;
|
||||||
|
|
||||||
|
async function loadSettings() {
|
||||||
|
try {
|
||||||
|
const statusRes = await fetch('/api/platforms/twitch/status');
|
||||||
|
const statusData = await statusRes.json();
|
||||||
|
const statusSpan = document.getElementById('twitch-status');
|
||||||
|
if (statusData.connected) {
|
||||||
|
statusSpan.textContent = 'Подключен';
|
||||||
|
statusSpan.className = 'status online';
|
||||||
|
} else {
|
||||||
|
statusSpan.textContent = 'Отключен';
|
||||||
|
statusSpan.className = 'status offline';
|
||||||
|
}
|
||||||
|
|
||||||
|
const authRes = await fetch('/api/platforms/twitch/auth');
|
||||||
|
const authData = await authRes.json();
|
||||||
|
document.getElementById('twitch-channel').textContent = authData.username || 'не указан';
|
||||||
|
const tokensDiv = document.getElementById('twitch-tokens-info');
|
||||||
|
if (authData.hasToken) {
|
||||||
|
tokensDiv.innerHTML = `<p>🔑 Токен бота: ${authData.maskedToken}</p>`;
|
||||||
|
loadTokenExpiry();
|
||||||
|
} else {
|
||||||
|
tokensDiv.innerHTML = '<p style="color: orange;">⚠️ Токен бота не настроен. Авторизуйте бота.</p>';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки настроек:', err);
|
||||||
|
document.getElementById('twitch-status').textContent = 'Ошибка';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateStatus() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/platforms/twitch/status');
|
||||||
|
const data = await res.json();
|
||||||
|
const statusSpan = document.getElementById('twitch-status');
|
||||||
|
if (data.connected) {
|
||||||
|
statusSpan.textContent = 'Подключен';
|
||||||
|
statusSpan.className = 'status online';
|
||||||
|
} else {
|
||||||
|
statusSpan.textContent = 'Отключен';
|
||||||
|
statusSpan.className = 'status offline';
|
||||||
|
}
|
||||||
|
} catch (err) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authStreamer() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/platforms/twitch/auth/user');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.url) {
|
||||||
|
window.open(data.url, '_blank', 'width=600,height=700');
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
pollInterval = setInterval(() => checkTokenUpdated('user'), 2000);
|
||||||
|
} else {
|
||||||
|
alert('Не удалось получить ссылку для авторизации');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authBot() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/platforms/twitch/auth/bot');
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.url) {
|
||||||
|
document.getElementById('auth-link').value = data.url;
|
||||||
|
document.getElementById('auth-link-container').style.display = 'block';
|
||||||
|
if (pollInterval) clearInterval(pollInterval);
|
||||||
|
pollInterval = setInterval(() => checkTokenUpdated('bot'), 2000);
|
||||||
|
} else {
|
||||||
|
alert('Не удалось получить ссылку для авторизации');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Ошибка: ' + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTokenExpiry() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/platforms/twitch/token_expiry');
|
||||||
|
const data = await res.json();
|
||||||
|
const userSpan = document.getElementById('user-expiry');
|
||||||
|
const botSpan = document.getElementById('bot-expiry');
|
||||||
|
if (data.user_expiry_days !== null && data.user_expiry_days !== undefined) {
|
||||||
|
userSpan.textContent = data.user_expiry_days + ' дн.';
|
||||||
|
if (data.user_expiry_days < 1) userSpan.style.color = 'red';
|
||||||
|
else if (data.user_expiry_days < 3) userSpan.style.color = 'orange';
|
||||||
|
else userSpan.style.color = 'green';
|
||||||
|
} else {
|
||||||
|
userSpan.textContent = 'не авторизован';
|
||||||
|
}
|
||||||
|
if (data.bot_expiry_days !== null && data.bot_expiry_days !== undefined) {
|
||||||
|
botSpan.textContent = data.bot_expiry_days + ' дн.';
|
||||||
|
if (data.bot_expiry_days < 1) botSpan.style.color = 'red';
|
||||||
|
else if (data.bot_expiry_days < 3) botSpan.style.color = 'orange';
|
||||||
|
else botSpan.style.color = 'green';
|
||||||
|
} else {
|
||||||
|
botSpan.textContent = 'не авторизован';
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка загрузки срока токенов:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showMessage(text, isError = false) {
|
||||||
|
const notif = document.getElementById('notification');
|
||||||
|
notif.textContent = text;
|
||||||
|
notif.style.backgroundColor = isError ? '#d9534f' : '#5cb85c';
|
||||||
|
notif.style.display = 'block';
|
||||||
|
setTimeout(() => { notif.style.display = 'none'; }, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkTokenUpdated(type) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/platforms/twitch/token_check?type=${type}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.updated) {
|
||||||
|
clearInterval(pollInterval);
|
||||||
|
pollInterval = null;
|
||||||
|
if (type === 'bot') {
|
||||||
|
document.getElementById('auth-link-container').style.display = 'none';
|
||||||
|
}
|
||||||
|
showMessage(`✅ Токен ${type === 'user' ? 'стримера' : 'бота'} сохранён!`, false);
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Ошибка проверки токена:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('copy-link-btn').addEventListener('click', () => {
|
||||||
|
const linkInput = document.getElementById('auth-link');
|
||||||
|
linkInput.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
alert('Ссылка скопирована! Откройте её в другом браузере.');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('auth-user-btn').onclick = authStreamer;
|
||||||
|
document.getElementById('auth-bot-btn').onclick = authBot;
|
||||||
|
|
||||||
|
loadSettings();
|
||||||
|
setInterval(updateStatus, 10000);
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
{{define "content"}}
|
||||||
|
<h2>Веб-сервисы для OBS</h2>
|
||||||
|
<p>Создавайте несколько независимых оверлеев чата и оповещений. Каждый сервис работает на своём порту.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<button id="add-chat-btn" style="margin-right:10px;">➕ Добавить чат-оверлей</button>
|
||||||
|
<button id="add-alert-btn">🔔 Добавить оверлей оповещений</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Тестирование оповещений</h3>
|
||||||
|
<label>Выберите alert-сервис:</label>
|
||||||
|
<select id="test-alert-service"></select>
|
||||||
|
<label>Тип события:</label>
|
||||||
|
<select id="test-alert-event">
|
||||||
|
<option value="follow">Фолловер</option>
|
||||||
|
<option value="subscribe">Подписка</option>
|
||||||
|
<option value="gift_sub">Подарочная подписка</option>
|
||||||
|
<option value="raid">Рейд</option>
|
||||||
|
<option value="reward_redemption">Награда</option>
|
||||||
|
</select>
|
||||||
|
<button id="test-alert-btn">🔔 Тестировать оповещение</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h3>Список сервисов</h3>
|
||||||
|
<table id="services-table" style="width:100%; border-collapse: collapse;">
|
||||||
|
<thead>
|
||||||
|
<tr><th>ID</th><th>Тип</th><th>Порт</th><th>Статус</th><th>Действия</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr><td colspan="5">Загрузка...</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Модальное окно для создания/редактирования -->
|
||||||
|
<div id="service-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<h3 id="modal-title">Создание сервиса</h3>
|
||||||
|
<form id="service-form">
|
||||||
|
<input type="hidden" id="service-id" value="0">
|
||||||
|
<label>Порт: <input type="number" id="service-port" required style="width:100%;"></label>
|
||||||
|
<label><input type="checkbox" id="service-enabled" checked> Сервис активен</label>
|
||||||
|
|
||||||
|
<!-- Настройки чата (показываются только для типа chat) -->
|
||||||
|
<div id="chat-config-fields" style="display:none;">
|
||||||
|
<h4>Настройки чата</h4>
|
||||||
|
<label>Фон (цвет): <input type="color" id="chat-bg-color" value="#000000"></label>
|
||||||
|
<label>Цвет текста: <input type="color" id="chat-text-color" value="#ffffff"></label>
|
||||||
|
<label>Размер шрифта (px): <input type="number" id="chat-font-size" min="10" max="50" value="14"></label>
|
||||||
|
<label>Шрифт: <input type="text" id="chat-font-family" value="Arial, sans-serif"></label>
|
||||||
|
<label>Прозрачность (%): <input type="range" id="chat-opacity" min="0" max="100" value="80"> <span id="opacity-val">80</span></label>
|
||||||
|
<label>Таймаут сообщения (сек): <input type="number" id="chat-timeout" min="0" max="60" value="10"></label>
|
||||||
|
<label>Макс. сообщений: <input type="number" id="chat-max-msgs" min="1" max="100" value="20"></label>
|
||||||
|
<label><input type="checkbox" id="chat-show-badges" checked> Показывать значки</label>
|
||||||
|
<label><input type="checkbox" id="chat-show-timestamps"> Показывать время</label>
|
||||||
|
|
||||||
|
<div class="preview-box">
|
||||||
|
<strong>Превью:</strong>
|
||||||
|
<div id="chat-preview" class="chat-preview" style="background:#000; color:#fff; padding:8px; border-radius:4px; margin-top:6px;">
|
||||||
|
<span style="font-weight:bold;">TestUser:</span> Привет, мир!
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Для alert-сервиса ничего лишнего не показываем -->
|
||||||
|
<div id="alert-config-fields" style="display:none;">
|
||||||
|
<p class="info">Оповещения настраиваются через HTTP-запросы к эндпоинту <code>/notify</code> этого сервиса. Все уведомления будут отображаться автоматически.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-top:15px;">
|
||||||
|
<button type="submit">Сохранить</button>
|
||||||
|
<button type="button" id="close-modal">Отмена</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let currentType = 'chat';
|
||||||
|
let services = [];
|
||||||
|
|
||||||
|
async function loadServices() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/webservices');
|
||||||
|
if (!res.ok) throw new Error('HTTP ' + res.status);
|
||||||
|
services = await res.json();
|
||||||
|
if (!Array.isArray(services)) services = [];
|
||||||
|
const tbody = document.querySelector('#services-table tbody');
|
||||||
|
if (!services.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">Нет сервисов. Создайте первый.</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = services.map(s => `
|
||||||
|
<tr>
|
||||||
|
<td>${s.id}</td>
|
||||||
|
<td>${s.service_type === 'chat' ? 'Чат' : 'Оповещения'}</td>
|
||||||
|
<td>${s.port}</td>
|
||||||
|
<td class="status-${s.id}">${s.running ? '✅ Запущен' : (s.enabled ? '⏹ Остановлен' : '❌ Отключен')}</td>
|
||||||
|
<td>
|
||||||
|
<button onclick="startService(${s.id})">▶ Запустить</button>
|
||||||
|
<button onclick="stopService(${s.id})">⏹ Остановить</button>
|
||||||
|
<button onclick="editService(${s.id})">✏️ Редактировать</button>
|
||||||
|
<button onclick="deleteService(${s.id})">🗑 Удалить</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
`).join('');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('loadServices error:', err);
|
||||||
|
document.querySelector('#services-table tbody').innerHTML = '<tr><td colspan="5">Ошибка загрузки</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startService(id) {
|
||||||
|
const res = await fetch(`/api/webservices/start?id=${id}`, { method: 'POST' });
|
||||||
|
if (res.ok) loadServices(); else alert('Ошибка запуска');
|
||||||
|
}
|
||||||
|
async function stopService(id) {
|
||||||
|
const res = await fetch(`/api/webservices/stop?id=${id}`, { method: 'POST' });
|
||||||
|
if (res.ok) loadServices(); else alert('Ошибка остановки');
|
||||||
|
}
|
||||||
|
async function deleteService(id) {
|
||||||
|
if (!confirm('Удалить сервис?')) return;
|
||||||
|
const res = await fetch(`/api/webservices/delete?id=${id}`, { method: 'DELETE' });
|
||||||
|
if (res.ok) loadServices(); else alert('Ошибка удаления');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function editService(id) {
|
||||||
|
const service = services.find(s => s.id === id);
|
||||||
|
if (!service) return;
|
||||||
|
currentType = service.service_type;
|
||||||
|
document.getElementById('service-id').value = service.id;
|
||||||
|
document.getElementById('service-port').value = service.port;
|
||||||
|
document.getElementById('service-enabled').checked = service.enabled;
|
||||||
|
|
||||||
|
if (currentType === 'chat') {
|
||||||
|
const cfg = service.config_json ? JSON.parse(service.config_json) : {};
|
||||||
|
document.getElementById('chat-bg-color').value = cfg.bg_color || '#000000';
|
||||||
|
document.getElementById('chat-text-color').value = cfg.text_color || '#ffffff';
|
||||||
|
document.getElementById('chat-font-size').value = cfg.font_size || 14;
|
||||||
|
document.getElementById('chat-font-family').value = cfg.font_family || 'Arial, sans-serif';
|
||||||
|
document.getElementById('chat-opacity').value = cfg.opacity || 80;
|
||||||
|
document.getElementById('opacity-val').innerText = cfg.opacity || 80;
|
||||||
|
document.getElementById('chat-timeout').value = cfg.message_timeout_sec || 10;
|
||||||
|
document.getElementById('chat-max-msgs').value = cfg.max_messages || 20;
|
||||||
|
document.getElementById('chat-show-badges').checked = cfg.show_badges !== undefined ? cfg.show_badges : true;
|
||||||
|
document.getElementById('chat-show-timestamps').checked = cfg.show_timestamps || false;
|
||||||
|
updateChatPreview();
|
||||||
|
}
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateChatPreview() {
|
||||||
|
const bg = document.getElementById('chat-bg-color').value;
|
||||||
|
const color = document.getElementById('chat-text-color').value;
|
||||||
|
const fontSize = document.getElementById('chat-font-size').value;
|
||||||
|
const fontFamily = document.getElementById('chat-font-family').value;
|
||||||
|
const opacity = document.getElementById('chat-opacity').value;
|
||||||
|
const showBadges = document.getElementById('chat-show-badges').checked;
|
||||||
|
const showTimestamps = document.getElementById('chat-show-timestamps').checked;
|
||||||
|
const previewDiv = document.getElementById('chat-preview');
|
||||||
|
previewDiv.style.backgroundColor = bg;
|
||||||
|
previewDiv.style.color = color;
|
||||||
|
previewDiv.style.fontSize = fontSize + 'px';
|
||||||
|
previewDiv.style.fontFamily = fontFamily;
|
||||||
|
previewDiv.style.opacity = opacity/100;
|
||||||
|
let badgesHtml = '';
|
||||||
|
if (showBadges) badgesHtml = '<span style="margin-right:4px;">🎭</span><span style="margin-right:4px;">👑</span><span>✔️</span> ';
|
||||||
|
let timeHtml = '';
|
||||||
|
if (showTimestamps) timeHtml = '<span style="color:#aaa; margin-right:8px;">12:34</span> ';
|
||||||
|
previewDiv.innerHTML = timeHtml + badgesHtml + '<span style="font-weight:bold;">TestUser:</span> Привет, мир!';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
document.getElementById('service-modal').style.display = 'flex';
|
||||||
|
document.getElementById('chat-config-fields').style.display = currentType === 'chat' ? 'block' : 'none';
|
||||||
|
document.getElementById('alert-config-fields').style.display = currentType === 'alert' ? 'block' : 'none';
|
||||||
|
document.getElementById('modal-title').innerText = (document.getElementById('service-id').value == 0 ? 'Создание' : 'Редактирование') + (currentType === 'chat' ? ' чат-оверлея' : ' оверлея оповещений');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openCreateDialog(type) {
|
||||||
|
currentType = type;
|
||||||
|
document.getElementById('service-id').value = 0;
|
||||||
|
document.getElementById('service-enabled').checked = true;
|
||||||
|
// Определяем свободный порт
|
||||||
|
const res = await fetch('/api/webservices');
|
||||||
|
let servicesList = [];
|
||||||
|
if (res.ok) {
|
||||||
|
servicesList = await res.json();
|
||||||
|
if (!Array.isArray(servicesList)) servicesList = [];
|
||||||
|
}
|
||||||
|
const usedPorts = servicesList.map(s => s.port);
|
||||||
|
let basePort = 9000;
|
||||||
|
while (usedPorts.includes(basePort)) basePort++;
|
||||||
|
document.getElementById('service-port').value = basePort;
|
||||||
|
|
||||||
|
if (type === 'chat') {
|
||||||
|
// Настройки чата по умолчанию
|
||||||
|
document.getElementById('chat-bg-color').value = '#000000';
|
||||||
|
document.getElementById('chat-text-color').value = '#ffffff';
|
||||||
|
document.getElementById('chat-font-size').value = 14;
|
||||||
|
document.getElementById('chat-font-family').value = 'Arial, sans-serif';
|
||||||
|
document.getElementById('chat-opacity').value = 80;
|
||||||
|
document.getElementById('opacity-val').innerText = '80';
|
||||||
|
document.getElementById('chat-timeout').value = 10;
|
||||||
|
document.getElementById('chat-max-msgs').value = 20;
|
||||||
|
document.getElementById('chat-show-badges').checked = true;
|
||||||
|
document.getElementById('chat-show-timestamps').checked = false;
|
||||||
|
updateChatPreview();
|
||||||
|
}
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('add-chat-btn').onclick = () => openCreateDialog('chat');
|
||||||
|
document.getElementById('add-alert-btn').onclick = () => openCreateDialog('alert');
|
||||||
|
document.getElementById('close-modal').onclick = () => document.getElementById('service-modal').style.display = 'none';
|
||||||
|
|
||||||
|
// Обработчики для превью чата
|
||||||
|
document.getElementById('chat-bg-color').addEventListener('input', updateChatPreview);
|
||||||
|
document.getElementById('chat-text-color').addEventListener('input', updateChatPreview);
|
||||||
|
document.getElementById('chat-font-size').addEventListener('input', updateChatPreview);
|
||||||
|
document.getElementById('chat-font-family').addEventListener('input', updateChatPreview);
|
||||||
|
document.getElementById('chat-opacity').addEventListener('input', (e) => { document.getElementById('opacity-val').innerText = e.target.value; updateChatPreview(); });
|
||||||
|
document.getElementById('chat-show-badges').addEventListener('change', updateChatPreview);
|
||||||
|
document.getElementById('chat-show-timestamps').addEventListener('change', updateChatPreview);
|
||||||
|
|
||||||
|
document.getElementById('service-form').onsubmit = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = parseInt(document.getElementById('service-id').value);
|
||||||
|
const port = parseInt(document.getElementById('service-port').value);
|
||||||
|
const enabled = document.getElementById('service-enabled').checked;
|
||||||
|
let config = {};
|
||||||
|
if (currentType === 'chat') {
|
||||||
|
config = {
|
||||||
|
bg_color: document.getElementById('chat-bg-color').value,
|
||||||
|
text_color: document.getElementById('chat-text-color').value,
|
||||||
|
font_size: parseInt(document.getElementById('chat-font-size').value),
|
||||||
|
font_family: document.getElementById('chat-font-family').value,
|
||||||
|
opacity: parseInt(document.getElementById('chat-opacity').value),
|
||||||
|
message_timeout_sec: parseInt(document.getElementById('chat-timeout').value),
|
||||||
|
max_messages: parseInt(document.getElementById('chat-max-msgs').value),
|
||||||
|
show_badges: document.getElementById('chat-show-badges').checked,
|
||||||
|
show_timestamps: document.getElementById('chat-show-timestamps').checked
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
// Для alert-сервиса отправляем пустой конфиг
|
||||||
|
config = {};
|
||||||
|
}
|
||||||
|
const url = id === 0 ? '/api/webservices/create' : `/api/webservices/update?id=${id}`;
|
||||||
|
const method = id === 0 ? 'POST' : 'PUT';
|
||||||
|
const body = JSON.stringify({ type: currentType, port, config, enabled });
|
||||||
|
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body });
|
||||||
|
if (res.ok) {
|
||||||
|
document.getElementById('service-modal').style.display = 'none';
|
||||||
|
loadServices();
|
||||||
|
} else {
|
||||||
|
const err = await res.text();
|
||||||
|
alert('Ошибка: ' + err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
async function loadAlertServices() {
|
||||||
|
const res = await fetch('/api/webservices/alert/list');
|
||||||
|
const services = await res.json();
|
||||||
|
const select = document.getElementById('test-alert-service');
|
||||||
|
select.innerHTML = '<option value="">-- выберите сервис --</option>';
|
||||||
|
services.forEach(s => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = s.id;
|
||||||
|
option.textContent = s.name;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('test-alert-btn').onclick = async () => {
|
||||||
|
const serviceId = document.getElementById('test-alert-service').value;
|
||||||
|
if (!serviceId) {
|
||||||
|
alert('Выберите alert-сервис');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const eventType = document.getElementById('test-alert-event').value;
|
||||||
|
const res = await fetch('/api/webservices/test/alert', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ serviceId: parseInt(serviceId), eventType: eventType })
|
||||||
|
});
|
||||||
|
if (res.ok) alert('Тестовое оповещение отправлено');
|
||||||
|
else alert('Ошибка');
|
||||||
|
};
|
||||||
|
|
||||||
|
// Вызываем при загрузке страницы
|
||||||
|
loadAlertServices();
|
||||||
|
loadServices();
|
||||||
|
setInterval(loadServices, 5000);
|
||||||
|
</script>
|
||||||
|
{{end}}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||||
|
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||||
|
<assemblyIdentity version="1.0.0.0" processorArchitecture="*" name="SomeFunkyNameHere" type="win32"/>
|
||||||
|
<dependency>
|
||||||
|
<dependentAssembly>
|
||||||
|
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0" processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||||
|
</dependentAssembly>
|
||||||
|
</dependency>
|
||||||
|
</assembly>
|
||||||
Reference in New Issue
Block a user