залил
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