├── .dockerignore ├── .github └── workflows │ ├── dockerimage.yml │ └── dotnetcore.yml ├── .gitignore ├── LICENSE ├── MomentumDiscordBot.sln ├── MomentumDiscordBot ├── Bot.cs ├── Commands │ ├── Admin │ │ ├── AdminConfigModule.cs │ │ ├── AdminModule.cs │ │ ├── AdminModuleBase.cs │ │ └── AdminTwitchBanModule.cs │ ├── Autocomplete │ │ ├── ActivityTypeChoiceProvider.cs │ │ ├── CustomCommandAutoCompleteProvider.cs │ │ ├── MessageAutoCompleteProvider.cs │ │ └── TimezoneAutoCompleteProvider.cs │ ├── Checks │ │ ├── DescriptiveCheckBaseAttribute.cs │ │ ├── RequireAdminBotChannelAttribute.cs │ │ ├── RequireUserAdminRoleAttribute.cs │ │ ├── RequireUserModeratorRoleAttribute.cs │ │ ├── RequireUserRoleAttribute.cs │ │ └── RequireUserTrustedRoleAttribute.cs │ ├── General │ │ └── GeneralModule.cs │ ├── Moderator │ │ ├── ModeratorCustomModule.cs │ │ ├── ModeratorDiscordEntityModule.cs │ │ ├── ModeratorMediaTrustModule.cs │ │ ├── ModeratorModule.cs │ │ ├── ModeratorModuleBase.cs │ │ ├── ModeratorStatus.cs │ │ ├── StatsGrowthModule.cs │ │ ├── StatsModule.cs │ │ └── StatsTopModule.cs │ └── MomentumModuleBase.cs ├── Constants │ ├── MomentumColor.cs │ └── PathConstants.cs ├── Dockerfile ├── Models │ ├── Configuration.cs │ ├── Data │ │ ├── DailyMessageCount.cs │ │ └── MomentumDiscordDbContext.cs │ ├── HiddenAttribute.cs │ ├── MicroserviceAttribute.cs │ └── MicroserviceType.cs ├── MomentumDiscordBot.csproj ├── Program.cs ├── Properties │ └── launchSettings.json ├── Services │ ├── DiscordEventService.cs │ ├── InteractivityService.cs │ ├── MessageHistoryService.cs │ ├── SlashCommandService.cs │ ├── StreamMonitorService.cs │ ├── TwitchApiService.cs │ └── UserTrustService.cs └── Utilities │ ├── DateTimeExtensions.cs │ ├── DbContextHelper.cs │ ├── DiscordEmbedBuilderExtensions.cs │ ├── DiscordExtensions.cs │ ├── FailedChecksExtensions.cs │ ├── MicroserviceExtensions.cs │ ├── StatsUtility.cs │ └── StringExtensions.cs ├── README.md ├── config └── config.template.json ├── data ├── init.sql └── setup-db.sh ├── docker-compose.override.yml ├── docker-compose.prod.yml ├── docker-compose.yml └── nuget.config /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md -------------------------------------------------------------------------------- /.github/workflows/dockerimage.yml: -------------------------------------------------------------------------------- 1 | name: Publish Docker 2 | on: 3 | push: 4 | branches: 5 | - net-core 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | permissions: 10 | packages: write 11 | steps: 12 | - uses: actions/checkout@v2 13 | - name: Publish to Registry 14 | uses: elgohr/Publish-Docker-Github-Action@v5 15 | with: 16 | name: momentum-mod/discord-bot/mmod-discord-bot 17 | username: ${{ github.actor }} 18 | password: ${{ secrets.GITHUB_TOKEN }} 19 | registry: docker.pkg.github.com 20 | dockerfile: MomentumDiscordBot/Dockerfile 21 | -------------------------------------------------------------------------------- /.github/workflows/dotnetcore.yml: -------------------------------------------------------------------------------- 1 | name: .NET Core 2 | 3 | on: 4 | push: 5 | branches: net-core 6 | paths-ignore: 7 | - README.md 8 | pull_request: 9 | branches: net-core 10 | paths-ignore: 11 | - README.md 12 | 13 | jobs: 14 | build: 15 | runs-on: ${{ matrix.os }} 16 | strategy: 17 | fail-fast: false 18 | matrix: 19 | os: [ubuntu-latest, windows-latest, macos-latest] 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Setup .NET Core 23 | uses: actions/setup-dotnet@v1 24 | with: 25 | dotnet-version: 6.0.x 26 | - name: Build with dotnet 27 | run: dotnet build --configuration Release 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Aa][Rr][Mm]/ 27 | [Aa][Rr][Mm]64/ 28 | bld/ 29 | [Bb]in/ 30 | [Oo]bj/ 31 | [Ll]og/ 32 | 33 | # Visual Studio 2015/2017 cache/options directory 34 | .vs/ 35 | # Uncomment if you have tasks that create the project's static files in wwwroot 36 | #wwwroot/ 37 | 38 | # Visual Studio 2017 auto generated files 39 | Generated\ Files/ 40 | 41 | # MSTest test Results 42 | [Tt]est[Rr]esult*/ 43 | [Bb]uild[Ll]og.* 44 | 45 | # NUNIT 46 | *.VisualState.xml 47 | TestResult.xml 48 | 49 | # Build Results of an ATL Project 50 | [Dd]ebugPS/ 51 | [Rr]eleasePS/ 52 | dlldata.c 53 | 54 | # Benchmark Results 55 | BenchmarkDotNet.Artifacts/ 56 | 57 | # .NET Core 58 | project.lock.json 59 | project.fragment.lock.json 60 | artifacts/ 61 | 62 | # StyleCop 63 | StyleCopReport.xml 64 | 65 | # Files built by Visual Studio 66 | *_i.c 67 | *_p.c 68 | *_h.h 69 | *.ilk 70 | *.meta 71 | *.obj 72 | *.iobj 73 | *.pch 74 | *.pdb 75 | *.ipdb 76 | *.pgc 77 | *.pgd 78 | *.rsp 79 | *.sbr 80 | *.tlb 81 | *.tli 82 | *.tlh 83 | *.tmp 84 | *.tmp_proj 85 | *_wpftmp.csproj 86 | *.log 87 | *.vspscc 88 | *.vssscc 89 | .builds 90 | *.pidb 91 | *.svclog 92 | *.scc 93 | 94 | # Chutzpah Test files 95 | _Chutzpah* 96 | 97 | # Visual C++ cache files 98 | ipch/ 99 | *.aps 100 | *.ncb 101 | *.opendb 102 | *.opensdf 103 | *.sdf 104 | *.cachefile 105 | *.VC.db 106 | *.VC.VC.opendb 107 | 108 | # Visual Studio profiler 109 | *.psess 110 | *.vsp 111 | *.vspx 112 | *.sap 113 | 114 | # Visual Studio Trace Files 115 | *.e2e 116 | 117 | # TFS 2012 Local Workspace 118 | $tf/ 119 | 120 | # Guidance Automation Toolkit 121 | *.gpState 122 | 123 | # ReSharper is a .NET coding add-in 124 | _ReSharper*/ 125 | *.[Rr]e[Ss]harper 126 | *.DotSettings.user 127 | 128 | # JustCode is a .NET coding add-in 129 | .JustCode 130 | 131 | # TeamCity is a build add-in 132 | _TeamCity* 133 | 134 | # DotCover is a Code Coverage Tool 135 | *.dotCover 136 | 137 | # AxoCover is a Code Coverage Tool 138 | .axoCover/* 139 | !.axoCover/settings.json 140 | 141 | # Visual Studio code coverage results 142 | *.coverage 143 | *.coveragexml 144 | 145 | # NCrunch 146 | _NCrunch_* 147 | .*crunch*.local.xml 148 | nCrunchTemp_* 149 | 150 | # MightyMoose 151 | *.mm.* 152 | AutoTest.Net/ 153 | 154 | # Web workbench (sass) 155 | .sass-cache/ 156 | 157 | # Installshield output folder 158 | [Ee]xpress/ 159 | 160 | # DocProject is a documentation generator add-in 161 | DocProject/buildhelp/ 162 | DocProject/Help/*.HxT 163 | DocProject/Help/*.HxC 164 | DocProject/Help/*.hhc 165 | DocProject/Help/*.hhk 166 | DocProject/Help/*.hhp 167 | DocProject/Help/Html2 168 | DocProject/Help/html 169 | 170 | # Click-Once directory 171 | publish/ 172 | 173 | # Publish Web Output 174 | *.[Pp]ublish.xml 175 | *.azurePubxml 176 | # Note: Comment the next line if you want to checkin your web deploy settings, 177 | # but database connection strings (with potential passwords) will be unencrypted 178 | *.pubxml 179 | *.publishproj 180 | 181 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 182 | # checkin your Azure Web App publish settings, but sensitive information contained 183 | # in these scripts will be unencrypted 184 | PublishScripts/ 185 | 186 | # NuGet Packages 187 | *.nupkg 188 | # The packages folder can be ignored because of Package Restore 189 | **/[Pp]ackages/* 190 | # except build/, which is used as an MSBuild target. 191 | !**/[Pp]ackages/build/ 192 | # Uncomment if necessary however generally it will be regenerated when needed 193 | #!**/[Pp]ackages/repositories.config 194 | # NuGet v3's project.json files produces more ignorable files 195 | *.nuget.props 196 | *.nuget.targets 197 | 198 | # Microsoft Azure Build Output 199 | csx/ 200 | *.build.csdef 201 | 202 | # Microsoft Azure Emulator 203 | ecf/ 204 | rcf/ 205 | 206 | # Windows Store app package directories and files 207 | AppPackages/ 208 | BundleArtifacts/ 209 | Package.StoreAssociation.xml 210 | _pkginfo.txt 211 | *.appx 212 | *.appxbundle 213 | *.appxupload 214 | 215 | # Visual Studio cache files 216 | # files ending in .cache can be ignored 217 | *.[Cc]ache 218 | # but keep track of directories ending in .cache 219 | !?*.[Cc]ache/ 220 | 221 | # Others 222 | ClientBin/ 223 | ~$* 224 | *~ 225 | *.dbmdl 226 | *.dbproj.schemaview 227 | *.jfm 228 | *.pfx 229 | *.publishsettings 230 | orleans.codegen.cs 231 | 232 | # Including strong name files can present a security risk 233 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 234 | #*.snk 235 | 236 | # Since there are multiple workflows, uncomment next line to ignore bower_components 237 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 238 | #bower_components/ 239 | 240 | # RIA/Silverlight projects 241 | Generated_Code/ 242 | 243 | # Backup & report files from converting an old project file 244 | # to a newer Visual Studio version. Backup files are not needed, 245 | # because we have git ;-) 246 | _UpgradeReport_Files/ 247 | Backup*/ 248 | UpgradeLog*.XML 249 | UpgradeLog*.htm 250 | ServiceFabricBackup/ 251 | *.rptproj.bak 252 | 253 | # SQL Server files 254 | *.mdf 255 | *.ldf 256 | *.ndf 257 | 258 | # Business Intelligence projects 259 | *.rdl.data 260 | *.bim.layout 261 | *.bim_*.settings 262 | *.rptproj.rsuser 263 | *- Backup*.rdl 264 | 265 | # Microsoft Fakes 266 | FakesAssemblies/ 267 | 268 | # GhostDoc plugin setting file 269 | *.GhostDoc.xml 270 | 271 | # Node.js Tools for Visual Studio 272 | .ntvs_analysis.dat 273 | node_modules/ 274 | 275 | # Visual Studio 6 build log 276 | *.plg 277 | 278 | # Visual Studio 6 workspace options file 279 | *.opt 280 | 281 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 282 | *.vbw 283 | 284 | # Visual Studio LightSwitch build output 285 | **/*.HTMLClient/GeneratedArtifacts 286 | **/*.DesktopClient/GeneratedArtifacts 287 | **/*.DesktopClient/ModelManifest.xml 288 | **/*.Server/GeneratedArtifacts 289 | **/*.Server/ModelManifest.xml 290 | _Pvt_Extensions 291 | 292 | # Paket dependency manager 293 | .paket/paket.exe 294 | paket-files/ 295 | 296 | # FAKE - F# Make 297 | .fake/ 298 | 299 | # CodeRush personal settings 300 | .cr/personal 301 | 302 | # Python Tools for Visual Studio (PTVS) 303 | __pycache__/ 304 | *.pyc 305 | 306 | # Cake - Uncomment if you are using it 307 | # tools/** 308 | # !tools/packages.config 309 | 310 | # Tabs Studio 311 | *.tss 312 | 313 | # Telerik's JustMock configuration file 314 | *.jmconfig 315 | 316 | # BizTalk build output 317 | *.btp.cs 318 | *.btm.cs 319 | *.odx.cs 320 | *.xsd.cs 321 | 322 | # OpenCover UI analysis results 323 | OpenCover/ 324 | 325 | # Azure Stream Analytics local run output 326 | ASALocalRun/ 327 | 328 | # MSBuild Binary and Structured Log 329 | *.binlog 330 | 331 | # NVidia Nsight GPU debugger configuration file 332 | *.nvuser 333 | 334 | # MFractors (Xamarin productivity tool) working folder 335 | .mfractor/ 336 | 337 | # Local History for Visual Studio 338 | .localhistory/ 339 | 340 | # BeatPulse healthcheck temp database 341 | healthchecksdb 342 | 343 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 344 | MigrationBackup/ 345 | 346 | ## 347 | ## Visual studio for Mac 348 | ## 349 | 350 | 351 | # globs 352 | Makefile.in 353 | *.userprefs 354 | *.usertasks 355 | config.make 356 | config.status 357 | aclocal.m4 358 | install-sh 359 | autom4te.cache/ 360 | *.tar.gz 361 | tarballs/ 362 | test-results/ 363 | 364 | # Mac bundle stuff 365 | *.dmg 366 | *.app 367 | 368 | # content below from: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 369 | # General 370 | .DS_Store 371 | .AppleDouble 372 | .LSOverride 373 | 374 | # Icon must end with two \r 375 | Icon 376 | 377 | 378 | # Thumbnails 379 | ._* 380 | 381 | # Files that might appear in the root of a volume 382 | .DocumentRevisions-V100 383 | .fseventsd 384 | .Spotlight-V100 385 | .TemporaryItems 386 | .Trashes 387 | .VolumeIcon.icns 388 | .com.apple.timemachine.donotpresent 389 | 390 | # Directories potentially created on remote AFP share 391 | .AppleDB 392 | .AppleDesktop 393 | Network Trash Folder 394 | Temporary Items 395 | .apdisk 396 | 397 | # content below from: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore 398 | # Windows thumbnail cache files 399 | Thumbs.db 400 | ehthumbs.db 401 | ehthumbs_vista.db 402 | 403 | # Dump file 404 | *.stackdump 405 | 406 | # Folder config file 407 | [Dd]esktop.ini 408 | 409 | # Recycle Bin used on file shares 410 | $RECYCLE.BIN/ 411 | 412 | # Windows Installer files 413 | *.cab 414 | *.msi 415 | *.msix 416 | *.msm 417 | *.msp 418 | 419 | # Windows shortcuts 420 | *.lnk 421 | 422 | # JetBrains Rider 423 | .idea/ 424 | *.sln.iml 425 | 426 | # Visual Studio Code 427 | .vscode/ 428 | 429 | # Config Files 430 | config/config.json 431 | 432 | # Docker Environment Files 433 | .env* 434 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Momentum Mod 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MomentumDiscordBot.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 16 4 | VisualStudioVersion = 16.0.30503.244 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MomentumDiscordBot", "MomentumDiscordBot\MomentumDiscordBot.csproj", "{48185D66-E823-4EF5-881D-4FBC247BD601}" 7 | EndProject 8 | Global 9 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 10 | Debug|Any CPU = Debug|Any CPU 11 | Release|Any CPU = Release|Any CPU 12 | EndGlobalSection 13 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 14 | {48185D66-E823-4EF5-881D-4FBC247BD601}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 15 | {48185D66-E823-4EF5-881D-4FBC247BD601}.Debug|Any CPU.Build.0 = Debug|Any CPU 16 | {48185D66-E823-4EF5-881D-4FBC247BD601}.Release|Any CPU.ActiveCfg = Release|Any CPU 17 | {48185D66-E823-4EF5-881D-4FBC247BD601}.Release|Any CPU.Build.0 = Release|Any CPU 18 | EndGlobalSection 19 | GlobalSection(SolutionProperties) = preSolution 20 | HideSolutionNode = FALSE 21 | EndGlobalSection 22 | GlobalSection(ExtensibilityGlobals) = postSolution 23 | SolutionGuid = {57589C22-EA46-4A37-B1A4-8ED368FB3040} 24 | EndGlobalSection 25 | EndGlobal 26 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Bot.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using System.Threading.Tasks; 4 | using DSharpPlus; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Logging; 7 | using MomentumDiscordBot.Models; 8 | using MomentumDiscordBot.Utilities; 9 | using Serilog; 10 | using ILogger = Serilog.ILogger; 11 | 12 | namespace MomentumDiscordBot 13 | { 14 | public class Bot 15 | { 16 | private readonly Configuration _config; 17 | private readonly DiscordClient _discordClient; 18 | private readonly ILogger _logger; 19 | 20 | public Bot(Configuration config, ILogger logger) 21 | { 22 | _config = config; 23 | _logger = logger; 24 | 25 | var logFactory = new LoggerFactory().AddSerilog(logger); 26 | 27 | _discordClient = new DiscordClient(new DiscordConfiguration 28 | { 29 | Token = _config.BotToken, 30 | TokenType = TokenType.Bot, 31 | AutoReconnect = true, 32 | MinimumLogLevel = LogLevel.None, 33 | LoggerFactory = logFactory, 34 | MessageCacheSize = 512, 35 | Intents = DiscordIntents.All 36 | }); 37 | 38 | var services = BuildServiceProvider(); 39 | services.InitializeMicroservices(Assembly.GetEntryAssembly()); 40 | } 41 | 42 | private IServiceProvider BuildServiceProvider() 43 | => new ServiceCollection() 44 | .AddSingleton(_config) 45 | .AddSingleton(_logger) 46 | .AddSingleton(_discordClient) 47 | .InjectMicroservices(Assembly.GetEntryAssembly()) 48 | .BuildServiceProvider(); 49 | 50 | public async Task StartAsync() 51 | => await _discordClient.ConnectAsync(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Admin/AdminConfigModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using System.Linq; 4 | using System.Reflection; 5 | using System.Threading.Tasks; 6 | using DSharpPlus; 7 | using DSharpPlus.SlashCommands; 8 | using DSharpPlus.Entities; 9 | using MomentumDiscordBot.Constants; 10 | using MomentumDiscordBot.Models; 11 | using HiddenAttribute = MomentumDiscordBot.Models.HiddenAttribute; 12 | 13 | namespace MomentumDiscordBot.Commands.Admin 14 | { 15 | [SlashCommandGroup("config", "show and modify the bot config")] 16 | public class AdminConfigModule : AdminModuleBase 17 | { 18 | public Configuration Config { get; set; } 19 | 20 | [SlashCommand("ls", "List config options")] 21 | public async Task GetConfigOptionsAsync(InteractionContext context, [Option("search", "search")] string search = null) 22 | { 23 | var configProperties = Config.GetType().GetProperties() 24 | .Where(x => !x.GetCustomAttributes().Any(x => x.GetType() == typeof(HiddenAttribute))).ToArray(); 25 | 26 | if (search != null) 27 | { 28 | configProperties = configProperties 29 | .Where(x => x.Name.Contains(search, StringComparison.InvariantCultureIgnoreCase)).ToArray(); 30 | } 31 | 32 | await ReplyNewEmbedAsync(context, string.Join(Environment.NewLine, configProperties.Select(x => x.Name)), 33 | MomentumColor.Blue); 34 | } 35 | 36 | [SlashCommand("set", "Sets config option")] 37 | public async Task SetConfigOptionAsync(InteractionContext context, [Option("key", "key")] string key, [Option("RemainingText", "RemainingText")] string value) 38 | { 39 | var configProperties = Config.GetType().GetProperties(); 40 | 41 | var selectedProperty = 42 | configProperties.Where(x => !x.GetCustomAttributes().Any(x => x.GetType() == typeof(HiddenAttribute))) 43 | .FirstOrDefault(x => x.Name.Equals(key, StringComparison.InvariantCultureIgnoreCase)); 44 | 45 | if (selectedProperty != null) 46 | { 47 | var setter = selectedProperty.GetSetMethod(); 48 | var setterParameters = setter.GetParameters(); 49 | if (setterParameters.Length != 1) 50 | { 51 | throw new Exception("Expected 1 parameter for the config setter."); 52 | } 53 | 54 | var configParameterType = setterParameters[0].ParameterType; 55 | 56 | if (configParameterType == typeof(string)) 57 | { 58 | setter.Invoke(Config, new[] { value }); 59 | } 60 | else 61 | { 62 | try 63 | { 64 | var convertedValue = TypeDescriptor.GetConverter(configParameterType).ConvertFromString(value); 65 | setter.Invoke(Config, new[] { convertedValue }); 66 | } 67 | catch (FormatException) 68 | { 69 | 70 | await ReplyNewEmbedAsync(context, $"Can't convert '{value}' to '{selectedProperty.PropertyType}.", MomentumColor.Red); 71 | return; 72 | } 73 | } 74 | 75 | await Config.SaveToFileAsync(); 76 | await ReplyNewEmbedAsync(context, $"Set '{selectedProperty.Name}' to '{value}'", MomentumColor.Blue); 77 | } 78 | else 79 | { 80 | await ReplyNewEmbedAsync(context, $"No config property found for '{key}'", DiscordColor.Orange); 81 | } 82 | } 83 | 84 | [SlashCommand("get", "Gets config option")] 85 | public async Task GetConfigOptionAsync(InteractionContext context, [Option("key", "key")] string key) 86 | { 87 | var configProperty = Config.GetType().GetProperties().Where(x => 88 | !x.GetCustomAttributes().Any(x => x.GetType() == typeof(HiddenAttribute)) && 89 | x.Name.Contains(key, StringComparison.InvariantCultureIgnoreCase)).ToList(); 90 | 91 | if (!configProperty.Any()) 92 | { 93 | await ReplyNewEmbedAsync(context, $"Could not find a config option for '{key}'.", DiscordColor.Orange); 94 | } 95 | else if (configProperty.Count > 1) 96 | { 97 | await ReplyNewEmbedAsync(context, $"More than one matching key found for '{key}'.", DiscordColor.Orange); 98 | } 99 | else 100 | { 101 | await ReplyNewEmbedAsync(context, 102 | Formatter.Sanitize(configProperty[0].GetGetMethod().Invoke(Config, Array.Empty()).ToString()), 103 | MomentumColor.Blue); 104 | } 105 | } 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Admin/AdminModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using DSharpPlus; 4 | using DSharpPlus.SlashCommands; 5 | using DSharpPlus.Entities; 6 | 7 | namespace MomentumDiscordBot.Commands.Admin 8 | { 9 | public class AdminModule : AdminModuleBase 10 | { 11 | public DiscordClient DiscordClient { get; set; } 12 | 13 | [SlashCommand("forcereconnect", "Simulates the Discord API requesting a reconnect")] 14 | public async Task ForceReconnectAsync(InteractionContext context, [Option("seconds", "seconds")] long seconds) 15 | { 16 | await context.CreateResponseAsync(InteractionResponseType.DeferredChannelMessageWithSource); 17 | await DiscordClient.DisconnectAsync(); 18 | await Task.Delay((int)(seconds * 1000)); 19 | await DiscordClient.ConnectAsync(); 20 | await context.EditResponseAsync(new DiscordWebhookBuilder().WithContent("Reconnected!")); 21 | } 22 | 23 | public const string ForcerestartCommandName = "forcerestart"; 24 | [SlashCommand(ForcerestartCommandName, "Forces the bot to exit the process, and have Docker auto-restart it")] 25 | public Task ForceRestartAsync(InteractionContext context) 26 | { 27 | Logger.Warning("{User} forced the bot to restart", context.User); 28 | 29 | _ = Task.Run(async () => 30 | { 31 | await ReplyNewEmbedAsync(context, "Restarting ...", DiscordColor.Orange); 32 | }); 33 | _ = Task.Run(async () => 34 | { 35 | await Task.Delay(5 * 1000); 36 | // Safe exit 37 | Environment.Exit(0); 38 | }); 39 | 40 | return Task.CompletedTask; 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Admin/AdminModuleBase.cs: -------------------------------------------------------------------------------- 1 | using MomentumDiscordBot.Commands.Checks; 2 | 3 | namespace MomentumDiscordBot.Commands.Admin 4 | { 5 | [RequireUserAdminRole] 6 | [RequireAdminBotChannel] 7 | public abstract class AdminModuleBase : MomentumModuleBase { } 8 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Admin/AdminTwitchBanModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using DSharpPlus; 5 | using DSharpPlus.SlashCommands; 6 | using DSharpPlus.Entities; 7 | using MomentumDiscordBot.Constants; 8 | using MomentumDiscordBot.Models; 9 | using MomentumDiscordBot.Services; 10 | 11 | namespace MomentumDiscordBot.Commands.Admin 12 | { 13 | // only twitch for now but calling this group "stream" 14 | // so the autocompletion shows updatestreams as well 15 | [SlashCommandGroup("streamBan", "twitch ban commands")] 16 | public class AdminTwitchBanModule : AdminModuleBase 17 | { 18 | public Configuration Config { get; set; } 19 | public StreamMonitorService StreamMonitorService { get; set; } 20 | 21 | 22 | [SlashCommand("add", "Hard ban a twitch user from the livestream channel")] 23 | public async Task AddTwitchBanAsync(InteractionContext context, [Option("RemainingText", "RemainingText")] string username) 24 | { 25 | var bans = (Config.TwitchUserBans ?? Array.Empty()).ToList(); 26 | 27 | var userToBanId = await StreamMonitorService.TwitchApiService.GetOrDownloadTwitchIDAsync(username); 28 | 29 | if (userToBanId == null) 30 | { 31 | await ReplyNewEmbedAsync(context, "Error getting the user's ID from the Twitch API, please try again.", 32 | DiscordColor.Orange); 33 | return; 34 | } 35 | 36 | bans.Add(userToBanId); 37 | Config.TwitchUserBans = bans.ToArray(); 38 | 39 | await Config.SaveToFileAsync(); 40 | 41 | // Force update 42 | StreamMonitorService.UpdateCurrentStreamersAsync(null); 43 | 44 | await ReplyNewEmbedAsync(context, $"Banned user with ID: {userToBanId}", DiscordColor.Orange); 45 | } 46 | 47 | [SlashCommand("remove", "Hard unban a twitch user from the livestream channel")] 48 | public async Task RemoveTwitchBanAsync(InteractionContext context, [Option("RemainingText", "RemainingText")] string username) 49 | { 50 | var bans = (Config.TwitchUserBans ?? Array.Empty()).ToList(); 51 | 52 | var userToUnbanId = await StreamMonitorService.TwitchApiService.GetOrDownloadTwitchIDAsync(username); 53 | 54 | if (userToUnbanId == null) 55 | { 56 | await ReplyNewEmbedAsync(context, "Error getting the user's ID from the Twitch API, please try again.", 57 | DiscordColor.Orange); 58 | return; 59 | } 60 | 61 | bans.Remove(userToUnbanId); 62 | Config.TwitchUserBans = bans.ToArray(); 63 | 64 | await Config.SaveToFileAsync(); 65 | 66 | StreamMonitorService.UpdateCurrentStreamersAsync(null); 67 | 68 | await ReplyNewEmbedAsync(context, $"Unbanned user with ID: {userToUnbanId}", DiscordColor.Orange); 69 | } 70 | 71 | [SlashCommand("list", "Get a list of Twitch users hard banned from the livestream channel")] 72 | public async Task ListTwitchBanAsync(InteractionContext context) 73 | { 74 | var bans = Config.TwitchUserBans ?? Array.Empty(); 75 | 76 | var banUsernameTasks = 77 | bans.Select(async x => await StreamMonitorService.TwitchApiService.GetStreamerNameAsync(x)); 78 | var usernames = await Task.WhenAll(banUsernameTasks); 79 | 80 | var embed = new DiscordEmbedBuilder 81 | { 82 | Title = "Twitch Banned IDs", 83 | Description = Formatter.Sanitize(string.Join(Environment.NewLine, usernames)), 84 | Color = MomentumColor.Blue 85 | }.Build(); 86 | 87 | await context.CreateResponseAsync(embed: embed); 88 | } 89 | 90 | [SlashCommand("softlist", "Get a list of Twitch users soft banned from the livestream channel")] 91 | public async Task ListTwitchSoftBansAsync(InteractionContext context) 92 | { 93 | var banUsernameTasks = StreamMonitorService.StreamSoftBanList.Select(async x => 94 | await StreamMonitorService.TwitchApiService.GetStreamerNameAsync(x)); 95 | 96 | var usernames = await Task.WhenAll(banUsernameTasks); 97 | 98 | var embed = new DiscordEmbedBuilder 99 | { 100 | Title = "Twitch Soft Banned IDs", 101 | Description = Formatter.Sanitize(string.Join(Environment.NewLine, usernames)), 102 | Color = MomentumColor.Blue 103 | }.Build(); 104 | 105 | await context.CreateResponseAsync(embed: embed); 106 | } 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Autocomplete/ActivityTypeChoiceProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using System.Collections.Generic; 4 | using DSharpPlus.Entities; 5 | using DSharpPlus.SlashCommands; 6 | 7 | namespace MomentumDiscordBot.Commands.Autocomplete 8 | { 9 | public class ActivityTypeChoiceProvider : IChoiceProvider 10 | { 11 | public Task> Provider() 12 | { 13 | ActivityType[] types = 14 | { 15 | ActivityType.Playing, 16 | ActivityType.Streaming, 17 | ActivityType.ListeningTo, 18 | ActivityType.Watching, 19 | ActivityType.Competing 20 | }; 21 | 22 | return Task.FromResult(types.Select(x => new DiscordApplicationCommandOptionChoice(x.GetName(), x.GetName()))); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Autocomplete/CustomCommandAutoCompleteProvider.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using System.Collections.Generic; 4 | using System.Reflection; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using DSharpPlus.Entities; 7 | using DSharpPlus.SlashCommands; 8 | using MomentumDiscordBot.Models; 9 | 10 | namespace MomentumDiscordBot.Commands.Autocomplete 11 | { 12 | public class AutoCompleteProvider : IAutocompleteProvider 13 | { 14 | private static IEnumerable FindCommand(IEnumerable commands, string s) 15 | { 16 | return commands.Where(x => x.Contains(s)).Take(25).OrderBy(x => x); 17 | } 18 | public Task> Provider(AutocompleteContext context) 19 | { 20 | var commands = context.Services.GetRequiredService().CustomCommands.Keys; 21 | var choices = FindCommand(commands, context.OptionValue.ToString()); 22 | if (!choices.Any()) 23 | { 24 | if (DiscordEmoji.TryFromName(context.Client, context.OptionValue.ToString(), out DiscordEmoji emoji)) 25 | { 26 | choices = FindCommand(commands, emoji.ToString()); 27 | } 28 | } 29 | return Task.FromResult(choices.Select(command => new DiscordAutoCompleteChoice(command, command))); 30 | } 31 | } 32 | 33 | public class CustomCommandPropertyChoiceProvider : IChoiceProvider 34 | { 35 | public Task> Provider() 36 | { 37 | var properties = typeof(CustomCommand).GetProperties(); 38 | var choices = properties.Where(x => !x.GetCustomAttributes() 39 | .Any(x => x.GetType() == typeof(HiddenAttribute))) 40 | .Take(25) 41 | .Select(property => new DiscordApplicationCommandOptionChoice(property.Name, property.Name)); 42 | return Task.FromResult(choices); 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Autocomplete/MessageAutoCompleteProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using System.Collections.Generic; 5 | using DSharpPlus.Entities; 6 | using DSharpPlus.SlashCommands; 7 | 8 | namespace MomentumDiscordBot.Commands.Autocomplete 9 | { 10 | public class MessageAutoCompleteProvider : IAutocompleteProvider 11 | { 12 | public async Task> Provider(AutocompleteContext context) 13 | { 14 | string search = context.OptionValue.ToString(); 15 | var channelMessages = await context.Channel.GetMessagesAsync(); 16 | return channelMessages 17 | .Where(x => !x.Author.IsBot && x.Author.IsSystem != true && x.Content.Contains(search)) 18 | .Take(25) 19 | .Select(x => new DiscordAutoCompleteChoice( 20 | string.Join("", $"{x.Author.Username}: {x.Content.Replace('\n', ' ')}".Take(100)), 21 | x.Id.ToString())); 22 | } 23 | } 24 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Autocomplete/TimezoneAutoCompleteProvider.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using System.Collections.Generic; 5 | using DSharpPlus.Entities; 6 | using DSharpPlus.SlashCommands; 7 | 8 | namespace MomentumDiscordBot.Commands.Autocomplete 9 | { 10 | public class TimezoneAutoCompleteProvider : IAutocompleteProvider 11 | { 12 | public Task> Provider(AutocompleteContext context) 13 | { 14 | string search = context.OptionValue.ToString().ToLower(); 15 | IEnumerable choices = TimeZoneInfo.GetSystemTimeZones(); 16 | if (!string.IsNullOrWhiteSpace(search)) 17 | choices = choices.Where(x => x.Id.ToLower().Contains(search)); 18 | return Task.FromResult(choices.Take(25).Select(x => new DiscordAutoCompleteChoice(x.Id, x.Id))); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Checks/DescriptiveCheckBaseAttribute.cs: -------------------------------------------------------------------------------- 1 | using DSharpPlus.SlashCommands; 2 | 3 | namespace MomentumDiscordBot.Commands.Checks 4 | { 5 | public abstract class DescriptiveCheckBaseAttribute : SlashCheckBaseAttribute 6 | { 7 | public string FailureResponse { get; set; } 8 | } 9 | public abstract class ContextMenuDescriptiveCheckBaseAttribute : ContextMenuCheckBaseAttribute 10 | { 11 | public string FailureResponse { get; set; } 12 | } 13 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Checks/RequireAdminBotChannelAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using DSharpPlus.SlashCommands; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using MomentumDiscordBot.Models; 5 | 6 | namespace MomentumDiscordBot.Commands.Checks 7 | { 8 | public class RequireAdminBotChannelAttribute : DescriptiveCheckBaseAttribute 9 | { 10 | public RequireAdminBotChannelAttribute() => FailureResponse = "Requires the bot admin channel"; 11 | 12 | public override Task ExecuteChecksAsync(InteractionContext context) 13 | { 14 | var config = context.Services.GetRequiredService(); 15 | return Task.FromResult(context.Channel.Id == config.AdminBotChannel); 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Checks/RequireUserAdminRoleAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using DSharpPlus.SlashCommands; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using MomentumDiscordBot.Models; 5 | 6 | namespace MomentumDiscordBot.Commands.Checks 7 | { 8 | public class RequireUserAdminRoleAttribute : RequireUserRoleAttribute 9 | { 10 | 11 | public override async Task ExecuteChecksAsync(InteractionContext context) 12 | { 13 | var baseExecutionResult = await base.ExecuteChecksAsync(context); 14 | if (baseExecutionResult) 15 | { 16 | return true; 17 | } 18 | 19 | var config = context.Services.GetRequiredService(); 20 | 21 | return context.User.Id == config.DeveloperID; 22 | } 23 | 24 | public RequireUserAdminRoleAttribute() 25 | { 26 | RoleIdSelector = configuration => configuration.AdminRoleID; 27 | FailureResponse = "Missing the Admin role"; 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Checks/RequireUserModeratorRoleAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace MomentumDiscordBot.Commands.Checks 2 | { 3 | public class RequireUserModeratorRoleAttribute : RequireUserRoleAttribute 4 | { 5 | public RequireUserModeratorRoleAttribute() 6 | { 7 | RoleIdSelector = configuration => configuration.ModeratorRoleID; 8 | FailureResponse = "Missing the Moderator role"; 9 | } 10 | } 11 | public class ContextMenuRequireUserModeratorRoleAttribute : ContextMenuRequireUserRoleAttribute 12 | { 13 | public ContextMenuRequireUserModeratorRoleAttribute() 14 | { 15 | RoleIdSelector = configuration => configuration.ModeratorRoleID; 16 | FailureResponse = "Missing the Moderator role"; 17 | } 18 | } 19 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Checks/RequireUserRoleAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using DSharpPlus.SlashCommands; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using MomentumDiscordBot.Models; 6 | using MomentumDiscordBot.Utilities; 7 | 8 | namespace MomentumDiscordBot.Commands.Checks 9 | { 10 | public abstract class RequireUserRoleAttribute : DescriptiveCheckBaseAttribute 11 | { 12 | protected Func RoleIdSelector; 13 | 14 | public override Task ExecuteChecksAsync(InteractionContext context) 15 | { 16 | var config = context.Services.GetRequiredService(); 17 | return Task.FromResult(context.User.RequireRole(RoleIdSelector(config))); 18 | } 19 | } 20 | public abstract class ContextMenuRequireUserRoleAttribute : ContextMenuDescriptiveCheckBaseAttribute 21 | { 22 | protected Func RoleIdSelector; 23 | 24 | public override Task ExecuteChecksAsync(ContextMenuContext context) 25 | { 26 | var config = context.Services.GetRequiredService(); 27 | return Task.FromResult(context.User.RequireRole(RoleIdSelector(config))); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Checks/RequireUserTrustedRoleAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace MomentumDiscordBot.Commands.Checks 2 | { 3 | public class RequireUserTrustedRoleAttribute : RequireUserRoleAttribute 4 | { 5 | public RequireUserTrustedRoleAttribute() 6 | { 7 | RoleIdSelector = configuration => configuration.MediaVerifiedRoleId; 8 | FailureResponse = "Missing the Trusted role"; 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/General/GeneralModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using System.Globalization; 4 | using DSharpPlus; 5 | using DSharpPlus.SlashCommands; 6 | using DSharpPlus.Entities; 7 | using MomentumDiscordBot.Models; 8 | using MomentumDiscordBot.Constants; 9 | using MomentumDiscordBot.Commands.Checks; 10 | using MomentumDiscordBot.Commands.Autocomplete; 11 | 12 | namespace MomentumDiscordBot.Commands.General 13 | { 14 | [RequireUserTrustedRole] 15 | public class GeneralModule : MomentumModuleBase 16 | { 17 | public Configuration Config { get; set; } 18 | 19 | public const string SayCommandName = "say"; 20 | 21 | [SlashCommand(SayCommandName, "Executes a custom command")] 22 | public async Task ExecCustomCommandAsync(InteractionContext context, 23 | [Autocomplete(typeof(AutoCompleteProvider))][Option("option", "Name of the custom command")] string name, 24 | [Autocomplete(typeof(MessageAutoCompleteProvider))][Option("reply", "Reply to this message")] string replyMessageId = null) 25 | { 26 | if (Config.CustomCommands.TryGetValue(name, out CustomCommand command)) 27 | { 28 | if (string.IsNullOrWhiteSpace(command.Title) && string.IsNullOrWhiteSpace(command.Description)) 29 | { 30 | //discord refuses to send messages without content 31 | command.Title = ""; 32 | } 33 | 34 | var embedBuilder = new DiscordEmbedBuilder 35 | { 36 | Title = command.Title, 37 | Description = command.Description, 38 | Color = MomentumColor.Blue 39 | }; 40 | if (Uri.IsWellFormedUriString(command.ThumbnailUrl, UriKind.Absolute)) 41 | { 42 | embedBuilder.Thumbnail = new DiscordEmbedBuilder.EmbedThumbnail 43 | { 44 | Url = command.ThumbnailUrl, 45 | Height = 90, 46 | Width = 160 47 | }; 48 | } 49 | if (Uri.IsWellFormedUriString(command.ImageUrl, UriKind.Absolute)) 50 | embedBuilder.ImageUrl = command.ImageUrl; 51 | 52 | if (replyMessageId is not null) 53 | { 54 | embedBuilder.Footer = new DiscordEmbedBuilder.EmbedFooter 55 | { 56 | Text = $"{context.User.Username}#{context.User.Discriminator} used /{context.CommandName} {name}", 57 | IconUrl = context.User.AvatarUrl 58 | }; 59 | } 60 | 61 | var message = new DiscordMessageBuilder() 62 | .AddEmbed(embedBuilder.Build()); 63 | 64 | if (Uri.IsWellFormedUriString(command.ButtonUrl, UriKind.Absolute)) 65 | message.AddComponents(new DiscordLinkButtonComponent(command.ButtonUrl, 66 | command.ButtonLabel ?? "Link")); 67 | if (ulong.TryParse(replyMessageId, out ulong id)) 68 | { 69 | DiscordMessage replyMessage; 70 | try 71 | { 72 | // check if the selected message is from this channel 73 | replyMessage = await context.Channel.GetMessageAsync(id); 74 | } 75 | catch (DSharpPlus.Exceptions.NotFoundException) 76 | { 77 | await context.CreateResponseAsync(new DiscordEmbedBuilder 78 | { 79 | Title = $"Can't find message {id} in this channel.", 80 | Color = MomentumColor.Red 81 | }, true); 82 | return; 83 | } 84 | await context.Channel.SendMessageAsync(message.WithReply(id, true)); 85 | await context.CreateResponseAsync(new DiscordEmbedBuilder 86 | { 87 | Title = $"Replied to message {id}.", 88 | Description = $"{replyMessage.JumpLink}", 89 | Color = MomentumColor.Blue 90 | }, true); 91 | } 92 | else 93 | { 94 | await context.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, 95 | new DiscordInteractionResponseBuilder(message)); 96 | } 97 | } 98 | else 99 | { 100 | await ReplyNewEmbedAsync(context, $"Command '{name}' doesn't exist!", MomentumColor.Red, true); 101 | } 102 | } 103 | 104 | [SlashCommand("timestamp", 105 | "Prints a timestamp, then when pasted in a message will be converted to all users local timezone")] 106 | public static async Task TimestampCommandAsync(InteractionContext context, 107 | [Option("timestamp", "The time you want to convert")] 108 | string timestamp, 109 | [Autocomplete(typeof(TimezoneAutoCompleteProvider))] [Option("timezone", "Your local timezone")] 110 | string timezone) 111 | { 112 | TimeZoneInfo timeZoneInfo; 113 | 114 | try 115 | { 116 | timeZoneInfo = TimeZoneInfo.FindSystemTimeZoneById(timezone); 117 | } 118 | catch (TimeZoneNotFoundException) 119 | { 120 | await TimestampConversionErrorMessage(context, 121 | "Invalid timezone, use one from the list of suggestions!", 122 | timestamp, timezone 123 | ); 124 | return; 125 | } 126 | 127 | //set culture so we know if 06.12.2022 is june or december 128 | var culture = new CultureInfo(context.Interaction.Locale); 129 | if (!DateTime.TryParse(timestamp, culture, DateTimeStyles.NoCurrentDateDefault, out DateTime dt)) 130 | { 131 | await TimestampConversionErrorMessage(context, 132 | "Make sure to order dates as your would in your native language.\n" + 133 | "This command uses https://docs.microsoft.com/en-us/dotnet/api/System.DateTime.TryParse with the locale provided by your Discord client.", 134 | timestamp, timezone 135 | ); 136 | return; 137 | } 138 | 139 | if (dt.Date == DateTime.MinValue) 140 | { 141 | //set "today" depending on the timezone if only time was set 142 | var today = TimeZoneInfo.ConvertTime(DateTime.Now, timeZoneInfo).Date; 143 | var time = dt.TimeOfDay; 144 | dt = today + time; 145 | } 146 | 147 | var dtNew = TimeZoneInfo.ConvertTimeToUtc(dt, timeZoneInfo); 148 | var unixTimestamp = ((DateTimeOffset)dtNew).ToUnixTimeSeconds(); 149 | string[] formats = 150 | { 151 | "", 152 | ":t", 153 | ":T", 154 | ":d", 155 | ":D", 156 | ":f", 157 | ":F", 158 | ":R", 159 | }; 160 | var embedBuilder = new DiscordEmbedBuilder 161 | { 162 | Title = $"Unix Timestamp for {timestamp} in {timezone}", 163 | Description = $"Parsed as: {dt.ToLongDateString()} {dt.ToLongTimeString()}.", 164 | Color = MomentumColor.Blue 165 | }; 166 | foreach (string format in formats) 167 | { 168 | var discordTimestamp = $"<t:{unixTimestamp}{format}>"; 169 | embedBuilder.AddField($"{discordTimestamp}", $"\\{discordTimestamp}"); 170 | } 171 | 172 | await context.CreateResponseAsync(embed: embedBuilder.Build(), true); 173 | } 174 | 175 | private static async Task TimestampConversionErrorMessage(InteractionContext context, string message, 176 | string timestamp, string timezone) 177 | { 178 | var embedBuilder = new DiscordEmbedBuilder 179 | { 180 | Title = 181 | $"Can't convert timestamp '{timestamp}', timezone '{timezone}'.", 182 | Description = message, 183 | Color = MomentumColor.Red 184 | }; 185 | await context.CreateResponseAsync(embed: embedBuilder.Build(), true); 186 | } 187 | } 188 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Moderator/ModeratorCustomModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.ComponentModel; 4 | using System.Collections.Generic; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using DSharpPlus; 8 | using DSharpPlus.SlashCommands; 9 | using DSharpPlus.Entities; 10 | using DSharpPlus.Interactivity.Extensions; 11 | using MomentumDiscordBot.Models; 12 | using MomentumDiscordBot.Constants; 13 | using MomentumDiscordBot.Commands.Autocomplete; 14 | using MomentumDiscordBot.Commands.General; 15 | 16 | namespace MomentumDiscordBot.Commands.Moderator 17 | { 18 | 19 | [SlashCommandGroup("custom", "Custom commands moderators can add during runtime and print a fixed response with /say")] 20 | public class CustomCommandModule : ModeratorModuleBase 21 | { 22 | const int modalTitleMaxLength = 45; 23 | const int embedTitleMaxLength = 256; 24 | const int embedFieldMaxLength = 1024; 25 | 26 | static int modalIdCounter = 0; 27 | public Configuration Config { get; set; } 28 | 29 | public static string CreateModalId(string name) 30 | { 31 | //the modalIdCounter is added in case one user starts multiple waits (i. e. cancels the popup and tries again) 32 | int id = Interlocked.Increment(ref modalIdCounter); 33 | return $"id-modal-{name}{id}"; 34 | } 35 | 36 | [ContextMenu(ApplicationCommandType.MessageContextMenu, "Add as custom command")] 37 | public async Task MessageMenu(ContextMenuContext context) 38 | { 39 | DiscordMessage message = context.TargetMessage; 40 | string name = "RENAME ME! " + message.Id; 41 | 42 | string title = ""; 43 | string description = message.Content; 44 | string buttonUrl = null; 45 | string buttonLabel = null; 46 | string thumbnailUrl = null; 47 | string imageUrl = null; 48 | 49 | if (message.Interaction is { Name: GeneralModule.SayCommandName }) 50 | { 51 | if (message.Embeds.Any()) 52 | { 53 | var embed = message.Embeds[0]; 54 | title = embed.Title; 55 | description = embed.Description; 56 | if (embed.Thumbnail is not null) 57 | thumbnailUrl = embed.Thumbnail.Url.ToString(); 58 | if (embed.Image is not null) 59 | imageUrl = embed.Image.Url.ToString(); 60 | } 61 | var component = message.Components.FirstOrDefault(x => x is DiscordLinkButtonComponent); 62 | if (component is DiscordLinkButtonComponent button) 63 | { 64 | buttonUrl = button.Url; 65 | buttonLabel = button.Label; 66 | } 67 | } 68 | 69 | const string nameFieldID = "name"; 70 | string modalId = CreateModalId("ascustom"); 71 | var modal = new DiscordInteractionResponseBuilder() 72 | .WithTitle("Custom command name") 73 | .WithCustomId(modalId) 74 | .AddComponents(new TextInputComponent( 75 | label: "Name", 76 | customId: nameFieldID, 77 | max_length: 100, 78 | style: TextInputStyle.Short)); 79 | 80 | bool success = false; 81 | await context.CreateResponseAsync(InteractionResponseType.Modal, modal); 82 | var interactivity = context.Client.GetInteractivity(); 83 | do 84 | { 85 | var response = await interactivity.WaitForModalAsync(modalId, user: context.User, timeoutOverride: TimeSpan.FromSeconds(30)); 86 | DiscordEmbedBuilder embedBuilder; 87 | if (response.TimedOut) 88 | { 89 | //can't respond to anything here 90 | return; 91 | } 92 | 93 | name = response.Result.Values[nameFieldID]; 94 | 95 | if (Config.CustomCommands.TryAdd(name, new CustomCommand(title, description, buttonUrl, buttonLabel, thumbnailUrl, imageUrl, context.User.Mention))) 96 | { 97 | await Config.SaveToFileAsync(); 98 | embedBuilder = new DiscordEmbedBuilder 99 | { 100 | Title = "", 101 | Description = "Command '" + name 102 | + "' created from message: " + message.JumpLink, 103 | Color = MomentumColor.Blue, 104 | }; 105 | await ReplyNewEmbedAsync(response.Result.Interaction, embedBuilder.Build(), true); 106 | success = true; 107 | } 108 | else 109 | { 110 | //the popup shows "something went wrong" here. 111 | } 112 | } while (!success); 113 | } 114 | 115 | [SlashCommand("add", "Creates a new custom commands")] 116 | public async Task AddCustomCommandAsync(InteractionContext context, [Option("name", "Name of the new command")] string name) 117 | { 118 | if (Config.CustomCommands.ContainsKey(name)) 119 | { 120 | await ReplyNewEmbedAsync(context, $"Command '{name}' already exists!", MomentumColor.Red, true); 121 | return; 122 | } 123 | 124 | const string titleFieldID = "id-title"; 125 | const string descriptionFieldID = "id-description"; 126 | string modalId = CreateModalId("customadd"); 127 | string modalTitle = $"Add custom command '{name}'"; 128 | if (modalTitle.Length > modalTitleMaxLength) 129 | { 130 | const string suffix = "...'"; 131 | modalTitle = string.Join("", modalTitle.Take(modalTitleMaxLength - suffix.Length)) + suffix; 132 | } 133 | var modal = new DiscordInteractionResponseBuilder() 134 | .WithTitle(modalTitle) 135 | .WithCustomId(modalId) 136 | .AddComponents(new TextInputComponent( 137 | label: "Title", 138 | customId: titleFieldID, 139 | max_length: embedTitleMaxLength, 140 | style: TextInputStyle.Short)) 141 | .AddComponents(new TextInputComponent( 142 | label: "Description", 143 | customId: descriptionFieldID, 144 | required: false, 145 | style: TextInputStyle.Paragraph)); 146 | 147 | await context.CreateResponseAsync(InteractionResponseType.Modal, modal); 148 | var interactivity = context.Client.GetInteractivity(); 149 | var response = await interactivity.WaitForModalAsync(modalId, user: context.User, timeoutOverride: TimeSpan.FromSeconds(5 * 60)); 150 | DiscordEmbedBuilder embedBuilder; 151 | if (response.TimedOut) 152 | { 153 | //can't respond to anything here 154 | return; 155 | } 156 | 157 | string title = response.Result.Values[titleFieldID]; 158 | string description = response.Result.Values[descriptionFieldID]; 159 | 160 | bool ephemeral; 161 | if (Config.CustomCommands.TryAdd(name, new CustomCommand(title, description, context.User.Mention))) 162 | { 163 | await Config.SaveToFileAsync(); 164 | embedBuilder = new DiscordEmbedBuilder 165 | { 166 | Title = $"Command '{name}' added.", 167 | Color = MomentumColor.Blue, 168 | }; 169 | ephemeral = false; 170 | } 171 | else 172 | { 173 | embedBuilder = new DiscordEmbedBuilder 174 | { 175 | Title = $"Failed to add command. '{name}' already exists.", 176 | Color = MomentumColor.Red, 177 | }; 178 | ephemeral = true; 179 | } 180 | 181 | await ReplyNewEmbedAsync(response.Result.Interaction, embedBuilder.Build(), ephemeral); 182 | } 183 | 184 | [SlashCommand("remove", "Deletes a custom commands")] 185 | public async Task RemoveCustomCommandAsync(InteractionContext context, [Autocomplete(typeof(AutoCompleteProvider))][Option("name", "Name of the custom command")] string name) 186 | { 187 | if (Config.CustomCommands.TryRemove(name, out _)) 188 | { 189 | await Config.SaveToFileAsync(); 190 | await ReplyNewEmbedAsync(context, $"Command '{name}' removed.", MomentumColor.Blue); 191 | } 192 | else 193 | { 194 | await ReplyNewEmbedAsync(context, "Failed to remove command.", MomentumColor.Blue); 195 | } 196 | } 197 | 198 | [SlashCommand("rename", "Deletes a custom commands")] 199 | public async Task RenameCustomCommandAsync(InteractionContext context, [Autocomplete(typeof(AutoCompleteProvider))][Option("oldName", "Name of the custom command")] string oldName, [Option("newName", "The new name")] string newName) 200 | { 201 | if (Config.CustomCommands.ContainsKey(newName)) 202 | await ReplyNewEmbedAsync(context, "Command '" + newName + "' already exists!", MomentumColor.Red); 203 | 204 | else if (Config.CustomCommands.ContainsKey(oldName)) 205 | { 206 | if (!Config.CustomCommands.TryGetValue(oldName, out CustomCommand command)) 207 | await ReplyNewEmbedAsync(context, "Failed to get old command value.", MomentumColor.Red); 208 | else if (!Config.CustomCommands.TryAdd(newName, command)) 209 | await ReplyNewEmbedAsync(context, "Failed to add new command.", MomentumColor.Red); 210 | else if (!Config.CustomCommands.TryRemove(oldName, out _)) 211 | await ReplyNewEmbedAsync(context, "Failed to remove old command.", MomentumColor.Red); 212 | else 213 | { 214 | await Config.SaveToFileAsync(); 215 | await ReplyNewEmbedAsync(context, $"Command '{oldName}' was renamed to '{newName}'.", MomentumColor.Blue); 216 | } 217 | } 218 | else 219 | { 220 | await ReplyNewEmbedAsync(context, $"Command '{oldName}' doesn't exist.", MomentumColor.Red); 221 | } 222 | } 223 | 224 | [SlashCommand("list", "Lists all custom commands")] 225 | public async Task ListCustomCommandAsync(InteractionContext context, [Option("page", "Which page to show, if > 25 commands.")] long page = 1) 226 | { 227 | string title = "Info Commands"; 228 | const int itemsPerPage = 25; 229 | int pages = (int)Math.Ceiling((double)Config.CustomCommands.Count / itemsPerPage); 230 | IEnumerable<KeyValuePair<string, CustomCommand>> commands = Config.CustomCommands.OrderByDescending(x => x.Value.CreationTimestamp); 231 | if (page < 1) 232 | page = 1; 233 | else if (page > pages) 234 | page = pages; 235 | if (pages > 1) 236 | { 237 | title += $" (Page {page}/{pages})"; 238 | commands = commands.Skip((int)(itemsPerPage * (page - 1))).Take(itemsPerPage); 239 | } 240 | var embedBuilder = new DiscordEmbedBuilder 241 | { 242 | Title = title, 243 | Color = MomentumColor.Blue 244 | }; 245 | foreach (var command in commands) 246 | { 247 | var unixTimestamp = ((DateTimeOffset)command.Value.CreationTimestamp).ToUnixTimeSeconds(); 248 | embedBuilder.AddField(command.Key, $"Added <t:{unixTimestamp}:R> by {command.Value.User ?? "<unknown>"}."); 249 | } 250 | 251 | await context.CreateResponseAsync(embed: embedBuilder.Build()); 252 | } 253 | 254 | [SlashCommand("edit", "Change a custom commands")] 255 | public async Task EditCustomCommandAsync(InteractionContext context, [Autocomplete(typeof(AutoCompleteProvider))][Option("name", "Name of the custom command")] string name, [ChoiceProvider(typeof(CustomCommandPropertyChoiceProvider))][Option("key", "What you want to change")] string key, [Option("value", "The new value")] string value = null) 256 | { 257 | DiscordInteraction inter = context.Interaction; 258 | if (Config.CustomCommands.TryGetValue(name, out CustomCommand command)) 259 | { 260 | var commandProperties = command.GetType().GetProperties(); 261 | 262 | var selectedProperty = 263 | commandProperties.FirstOrDefault(x => x.Name.Equals(key, StringComparison.InvariantCultureIgnoreCase)); 264 | if (key == nameof(CustomCommand.Description)) 265 | { 266 | const string valueFieldID = "id-value"; 267 | string modalId = CreateModalId("customedit"); 268 | 269 | string modalTitle = $"Edit custom command '{name}'"; 270 | if (modalTitle.Length > modalTitleMaxLength) 271 | { 272 | const string suffix = "...'"; 273 | modalTitle = string.Join("", modalTitle.Take(modalTitleMaxLength - suffix.Length)) + suffix; 274 | } 275 | var modal = new DiscordInteractionResponseBuilder() 276 | .WithTitle(modalTitle) 277 | .WithCustomId(modalId) 278 | .AddComponents(new TextInputComponent( 279 | label: key, 280 | customId: valueFieldID, 281 | value: command.Description, 282 | required: false, 283 | style: TextInputStyle.Paragraph)); 284 | 285 | await context.CreateResponseAsync(InteractionResponseType.Modal, modal); 286 | var interactivity = context.Client.GetInteractivity(); 287 | var response = await interactivity.WaitForModalAsync(modalId, user: context.User, timeoutOverride: TimeSpan.FromSeconds(5 * 60)); 288 | if (response.TimedOut) 289 | { 290 | //can't respond to anything here 291 | return; 292 | } 293 | 294 | inter = response.Result.Interaction; 295 | value = response.Result.Values[valueFieldID]; 296 | 297 | } 298 | 299 | if (selectedProperty != null) 300 | { 301 | var setter = selectedProperty.GetSetMethod(); 302 | var setterParameters = setter.GetParameters(); 303 | if (setterParameters.Length != 1) 304 | { 305 | throw new Exception("Expected 1 parameter for the config setter."); 306 | } 307 | 308 | var configParameterType = setterParameters[0].ParameterType; 309 | 310 | if (configParameterType == typeof(string)) 311 | { 312 | setter.Invoke(command, new[] { value }); 313 | } 314 | else 315 | { 316 | try 317 | { 318 | if (value is not null) 319 | { 320 | var convertedValue = TypeDescriptor.GetConverter(configParameterType).ConvertFromString(value); 321 | setter.Invoke(command, new[] { convertedValue }); 322 | } 323 | else 324 | setter.Invoke(command, Array.Empty<object>()); 325 | } 326 | catch (FormatException) 327 | { 328 | await ReplyNewEmbedAsync(inter, $"Can't convert '{value}' to '{selectedProperty.PropertyType}.", MomentumColor.Red); 329 | return; 330 | } 331 | 332 | } 333 | var buttonUrlProperty = typeof(CustomCommand).GetProperty(nameof(CustomCommand.ButtonUrl)); 334 | if (command.ThumbnailUrl is null 335 | && selectedProperty == buttonUrlProperty 336 | && Uri.IsWellFormedUriString(command.ButtonUrl, UriKind.Absolute)) 337 | { 338 | var link = new Uri(command.ButtonUrl); 339 | if (link.Host == "www.youtube.com") 340 | { 341 | var query = System.Web.HttpUtility.ParseQueryString(link.Query); 342 | string id = query["v"]; 343 | if (id is not null) 344 | command.ThumbnailUrl = $"https://img.youtube.com/vi/{id}/mqdefault.jpg"; 345 | } 346 | } 347 | 348 | await Config.SaveToFileAsync(); 349 | await ReplyNewEmbedAsync(inter, $"Set '{selectedProperty.Name}' to '{value}'.", MomentumColor.Blue); 350 | } 351 | else 352 | { 353 | await ReplyNewEmbedAsync(inter, $"No config property found for '{key}'.", DiscordColor.Orange); 354 | } 355 | } 356 | else 357 | { 358 | await ReplyNewEmbedAsync(inter, $"Command '{name}' doesn't exist.", MomentumColor.Red); 359 | } 360 | } 361 | [SlashCommand("info", "Prints command properties")] 362 | public async Task InfoCustomCommandAsync(InteractionContext context, [Autocomplete(typeof(AutoCompleteProvider))][Option("name", "Name of the custom command")] string name) 363 | { 364 | if (Config.CustomCommands.TryGetValue(name, out CustomCommand command)) 365 | { 366 | var embedBuilder = new DiscordEmbedBuilder 367 | { 368 | Title = $"Command '{name}' properties", 369 | Color = MomentumColor.Blue, 370 | 371 | }; 372 | var commandProperties = command.GetType().GetProperties(); 373 | foreach (var property in commandProperties) 374 | { 375 | object value = property.GetValue(command, null); 376 | string valueStr = value is null ? "<null>" : value.ToString(); 377 | const string trim = "..."; 378 | if (valueStr.Length + 2 > embedFieldMaxLength) 379 | { 380 | valueStr = valueStr[..(embedFieldMaxLength - 2 - trim.Length)] + trim; 381 | } 382 | embedBuilder.AddField(property.Name, $"'{valueStr}'"); 383 | } 384 | await context.CreateResponseAsync(embed: embedBuilder.Build()); 385 | } 386 | else 387 | { 388 | await ReplyNewEmbedAsync(context, $"Command '{name}' doesn't exist.", MomentumColor.Red); 389 | } 390 | } 391 | } 392 | } 393 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Moderator/ModeratorDiscordEntityModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using DSharpPlus; 5 | using DSharpPlus.SlashCommands; 6 | using DSharpPlus.Entities; 7 | using MomentumDiscordBot.Constants; 8 | using MomentumDiscordBot.Utilities; 9 | 10 | namespace MomentumDiscordBot.Commands.Moderator 11 | { 12 | [SlashCommandGroup("info", "Provides information about ...")] 13 | public class ModeratorDiscordEntityModule : ModeratorModuleBase 14 | { 15 | [SlashCommand("user", "Provides information about a user")] 16 | public static async Task GetUserInfoAsync(InteractionContext context, [Option("member", "member")] DiscordUser user) 17 | { 18 | DiscordMember member = (DiscordMember)user; 19 | var avatarUrl = member.AvatarUrl ?? member.DefaultAvatarUrl; 20 | var embed = new DiscordEmbedBuilder 21 | { 22 | Author = new DiscordEmbedBuilder.EmbedAuthor 23 | { 24 | IconUrl = avatarUrl, 25 | Name = member.Username 26 | } 27 | }; 28 | 29 | // If the user doesn't have a role then default to blue 30 | var highestRole = member.Roles.OrderByDescending(x => x.Position).FirstOrDefault(); 31 | embed.Color = highestRole?.Color ?? MomentumColor.Blue; 32 | 33 | embed.AddField("Mention", member.Mention); 34 | 35 | if (member.Roles.Any()) 36 | { 37 | embed.AddField("Roles", 38 | string.Join(" ", 39 | member.Roles.OrderByDescending(x => x.Position).Select(x => x.Mention))); 40 | } 41 | 42 | var dangerousPermissions = member.Roles.Select(x => x.Permissions) 43 | .Aggregate(Permissions.None, (nextPermission, allPermissions) => allPermissions | nextPermission) 44 | .GetDangerousPermissions().ToPermissionString(); 45 | if (dangerousPermissions.Any()) 46 | { 47 | embed.AddField("Dangerous Permissions", 48 | string.Join(" ", dangerousPermissions)); 49 | } 50 | 51 | embed.AddField("Joined", 52 | $"{(DateTime.UtcNow - member.JoinedAt).ToPrettyFormat()} ago"); 53 | 54 | 55 | embed.AddField("Account Created", 56 | $"{(DateTime.UtcNow - member.CreationTimestamp).ToPrettyFormat()} ago"); 57 | 58 | embed.WithFooter(member.Id.ToString()); 59 | 60 | await context.CreateResponseAsync(embed: embed); 61 | } 62 | 63 | [SlashCommand("role", "Provides information about a role")] 64 | public static async Task GetRoleInfoAsync(InteractionContext context, [Option("role", "role")] DiscordRole role) 65 | { 66 | string membersWithRoleMsg; 67 | if (role.Name == "@everyone") 68 | { 69 | membersWithRoleMsg = $"There are {context.Guild.Members.Count} members in total.\n"; 70 | } 71 | else 72 | { 73 | var membersWithRole = context.Guild.Members.Values.Count(x => x.Roles.Contains(role)); 74 | membersWithRoleMsg = $"{membersWithRole} users have {role.Mention}.\n"; 75 | } 76 | var embed = new DiscordEmbedBuilder 77 | { 78 | Description = membersWithRoleMsg + 79 | "See 'Members' page in the server settings for details" 80 | }; 81 | 82 | await context.CreateResponseAsync(embed: embed); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Moderator/ModeratorMediaTrustModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using DSharpPlus.SlashCommands; 5 | using DSharpPlus.Entities; 6 | using Microsoft.EntityFrameworkCore; 7 | using MomentumDiscordBot.Constants; 8 | using MomentumDiscordBot.Models; 9 | using MomentumDiscordBot.Utilities; 10 | 11 | namespace MomentumDiscordBot.Commands.Moderator 12 | { 13 | [SlashCommandGroup("trust", "media trust commands")] 14 | public class ModeratorMediaTrustModule : ModeratorModuleBase 15 | { 16 | public Configuration Config { get; set; } 17 | 18 | [SlashCommand("status", "Checks to see a member's media trust status")] 19 | public async Task MediaStatusAsync(InteractionContext context, [Option("member", "member")] DiscordUser user) 20 | { 21 | await context.DeferAsync(); 22 | 23 | DiscordMember member = (DiscordMember)user; 24 | var embedBuilder = new DiscordEmbedBuilder 25 | { 26 | Title = "Media Trust Status", 27 | Author = new DiscordEmbedBuilder.EmbedAuthor 28 | { 29 | Name = member.Username, 30 | IconUrl = member.AvatarUrl ?? member.DefaultAvatarUrl 31 | } 32 | }; 33 | 34 | await using var dbContext = DbContextHelper.GetNewDbContext(Config); 35 | 36 | var messages = await dbContext.DailyMessageCount.ToListAsync(); 37 | var userMessages = messages.Where(x => x.UserId == member.Id).ToList(); 38 | 39 | var oldestMessage = userMessages 40 | .OrderBy(x => x.Date) 41 | .FirstOrDefault(); 42 | 43 | if (oldestMessage == null) 44 | { 45 | embedBuilder.WithColor(MomentumColor.Red) 46 | .WithDescription("No recorded activity"); 47 | } 48 | else 49 | { 50 | var totalMessageCount = userMessages.Sum(x => x.MessageCount); 51 | var oldestMessageSpan = DateTime.UtcNow - oldestMessage.Date; 52 | var hasTrustedRole = member.Roles.Any(x => x.Id == Config.MediaVerifiedRoleId); 53 | var hasBlacklistedRole = member.Roles.Any(x => x.Id == Config.MediaBlacklistedRoleId); 54 | 55 | 56 | embedBuilder.WithColor(MomentumColor.Blue) 57 | .AddField("Oldest Message Sent", $"{oldestMessageSpan.ToPrettyFormat()} ago") 58 | .AddField("Total Messages", totalMessageCount.ToString()) 59 | .AddField("Meets Requirements", 60 | (oldestMessageSpan.TotalDays > Config.MediaMinimumDays && 61 | totalMessageCount > Config.MediaMinimumMessages).ToString()) 62 | .AddField("Has Trusted Role", hasTrustedRole.ToString()) 63 | .AddField("Has Blacklisted Role", hasBlacklistedRole.ToString()); 64 | 65 | if (hasBlacklistedRole) 66 | { 67 | embedBuilder.Color = MomentumColor.DarkestGray; 68 | } 69 | 70 | if (hasTrustedRole) 71 | { 72 | embedBuilder.Color = MomentumColor.Green; 73 | } 74 | } 75 | await context.EditResponseAsync(new DiscordWebhookBuilder().AddEmbed(embedBuilder)); 76 | } 77 | 78 | [SlashCommand("give", "Manually trusts a member, if applicable, removing the blacklist")] 79 | public async Task TrustUserAsync(InteractionContext context, [Option("member", "member")] DiscordUser user) 80 | { 81 | DiscordMember member = (DiscordMember)user; 82 | var trustedRole = context.Guild.GetRole(Config.MediaVerifiedRoleId); 83 | var blacklistRole = context.Guild.GetRole(Config.MediaBlacklistedRoleId); 84 | 85 | await member.GrantRoleAsync(trustedRole); 86 | await member.RevokeRoleAsync(blacklistRole); 87 | 88 | await ReplyNewEmbedAsync(context, "Trusted " + member.Mention, MomentumColor.Blue); 89 | } 90 | 91 | [SlashCommand("blacklist", "Manually blacklist a member, if applicable, removing the trust")] 92 | public async Task BlacklistUserAsync(InteractionContext context, [Option("member", "member")] DiscordUser user) 93 | { 94 | DiscordMember member = (DiscordMember)user; 95 | var trustedRole = context.Guild.GetRole(Config.MediaVerifiedRoleId); 96 | var blacklistRole = context.Guild.GetRole(Config.MediaBlacklistedRoleId); 97 | 98 | await member.RevokeRoleAsync(trustedRole); 99 | await member.GrantRoleAsync(blacklistRole); 100 | 101 | await ReplyNewEmbedAsync(context, "Blacklisted " + member.Mention, MomentumColor.Blue); 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Moderator/ModeratorModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using System.Text.Json; 6 | using DSharpPlus; 7 | using DSharpPlus.SlashCommands; 8 | using DSharpPlus.Entities; 9 | using MomentumDiscordBot.Constants; 10 | using MomentumDiscordBot.Models; 11 | using MomentumDiscordBot.Services; 12 | 13 | namespace MomentumDiscordBot.Commands.Moderator 14 | { 15 | public class ModeratorModule : ModeratorModuleBase 16 | { 17 | public StreamMonitorService StreamMonitorService { get; set; } 18 | 19 | public Configuration Config { get; set; } 20 | 21 | [SlashCommand("updatestreams", "Force an update of Twitch livestreams")] 22 | public async Task ForceUpdateStreamsAsync(InteractionContext context) 23 | { 24 | StreamMonitorService.UpdateCurrentStreamersAsync(null); 25 | 26 | await ReplyNewEmbedAsync(context, "Updating Livestreams", MomentumColor.Blue); 27 | } 28 | 29 | [SlashCommand("bans", "Returns a list of banned users")] 30 | public static async Task BansAsync(InteractionContext context) 31 | { 32 | 33 | await context.CreateResponseAsync(InteractionResponseType.DeferredChannelMessageWithSource); 34 | 35 | var bans = await context.Guild.GetBansAsync(); 36 | if (!bans.Any()) 37 | { 38 | await context.EditResponseAsync(new DiscordWebhookBuilder() 39 | .WithContent($"Everyone here is unbelievably nice. 0 bans.")); 40 | return; 41 | } 42 | 43 | string time = DateTime.UtcNow.ToString("yyyyMMddHHmmss"); 44 | string fileName = $"bans{time}.txt"; 45 | 46 | await using var fileStream = new MemoryStream(); 47 | 48 | //array instead of class to reduce sice (no field names) 49 | var data = bans.Select(x => new[] 50 | { 51 | $"{x.User.Username}#{x.User.Discriminator}", 52 | x.User.Id.ToString(), 53 | x.Reason 54 | }); 55 | await System.Text.Json.JsonSerializer.SerializeAsync(fileStream, data, new JsonSerializerOptions 56 | { 57 | WriteIndented = true 58 | }); 59 | await fileStream.FlushAsync(); 60 | fileStream.Seek(0, SeekOrigin.Begin); 61 | 62 | //Attaching the file could fail because of the size. 63 | //If it does we see the filesize and I don't have to catch a generic HttpRequestException 64 | await context.EditResponseAsync(new DiscordWebhookBuilder() 65 | .WithContent($"{bans.Count} banned users. Attaching {fileStream.Length / 1000 / 1000}MB file...")); 66 | await context.EditResponseAsync(new DiscordWebhookBuilder() 67 | .WithContent($"Here is a list of all {bans.Count} banned users.") 68 | .AddFile(fileName, fileStream)); 69 | } 70 | 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Moderator/ModeratorModuleBase.cs: -------------------------------------------------------------------------------- 1 | using MomentumDiscordBot.Commands.Checks; 2 | 3 | namespace MomentumDiscordBot.Commands.Moderator 4 | { 5 | [RequireUserModeratorRole] 6 | [RequireAdminBotChannel] 7 | [ContextMenuRequireUserModeratorRole] 8 | // ContextMenus are used /info and /custom and *not* restricted to the bot channel 9 | // they are not actually part of the commandgroup and show up separately in the integration settings 10 | public class ModeratorModuleBase : MomentumModuleBase { } 11 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Moderator/ModeratorStatus.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using DSharpPlus.SlashCommands; 5 | using DSharpPlus.Entities; 6 | using MomentumDiscordBot.Constants; 7 | using MomentumDiscordBot.Models; 8 | using MomentumDiscordBot.Services; 9 | using MomentumDiscordBot.Commands.Autocomplete; 10 | 11 | namespace MomentumDiscordBot.Commands.Moderator 12 | { 13 | [SlashCommandGroup("status", "bots status")] 14 | public class ModeratorStatus : ModeratorModuleBase 15 | { 16 | [SlashCommand("set", "Sets the bot's status")] 17 | public static async Task StatusAsync(InteractionContext context, 18 | [Option("status", "status")] string status, 19 | [ChoiceProvider(typeof(ActivityTypeChoiceProvider))][Option("type", "ActivityType")] string type = null) 20 | { 21 | var activity = Enum.TryParse(type, out ActivityType activityType) 22 | ? new DiscordActivity(status, activityType) 23 | : new DiscordActivity(status); 24 | await context.Client.UpdateStatusAsync(activity); 25 | await ReplyNewEmbedAsync(context, $"Status set to '{status}'.", MomentumColor.Blue); 26 | } 27 | 28 | [SlashCommand("clear", "Clears the bot's status")] 29 | public static async Task ClearStatusAsync(InteractionContext context) 30 | { 31 | await context.Client.UpdateStatusAsync(new DiscordActivity()); 32 | await ReplyNewEmbedAsync(context, "Status cleared.", MomentumColor.Blue); 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Moderator/StatsGrowthModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using System.Threading.Tasks; 6 | using DSharpPlus; 7 | using DSharpPlus.SlashCommands; 8 | using DSharpPlus.Entities; 9 | using MomentumDiscordBot.Constants; 10 | using MomentumDiscordBot.Models; 11 | using MomentumDiscordBot.Models.Data; 12 | using MomentumDiscordBot.Utilities; 13 | 14 | namespace MomentumDiscordBot.Commands.Moderator 15 | { 16 | [SlashCommandGroup("growth", "Compare message count")] 17 | public class StatsGrowthModule : ModeratorModuleBase 18 | { 19 | public Configuration Config { get; set; } 20 | 21 | [SlashCommand("user", "Compares a user's message count in two groups of 30 day periods")] 22 | public async Task UserStatsAsync(InteractionContext context, [Option("member", "member")] DiscordUser user) 23 | { 24 | await context.DeferAsync(); 25 | 26 | var member = (DiscordMember)user; 27 | 28 | var userStats = await StatsUtility.GetMessages(Config, x => x.UserId == member.Id); 29 | 30 | await context.EditResponseAsync( 31 | new DiscordWebhookBuilder().AddEmbed(GetGrowthEmbed(userStats, member.Mention))); 32 | } 33 | 34 | [SlashCommand("channel", "Compares a channels's message count in two groups of 30 day periods")] 35 | public async Task ChannelStatsAsync(InteractionContext context, [Option("channel", "channel")] DiscordChannel channel) 36 | { 37 | await context.DeferAsync(); 38 | 39 | if (channel.Type != ChannelType.Text) 40 | { 41 | await ReplyNewEmbedAsync(context, "Channel must be a text channel.", DiscordColor.Orange); 42 | return; 43 | } 44 | 45 | var channelStats = await StatsUtility.GetMessages(Config, x => x.ChannelId == channel.Id); 46 | 47 | await context.EditResponseAsync( 48 | new DiscordWebhookBuilder().AddEmbed(GetGrowthEmbed(channelStats, channel.Mention))); 49 | } 50 | 51 | private static DiscordEmbedBuilder GetGrowthEmbed(List<DailyMessageCount> filteredMessages, string mention) 52 | { 53 | // Filters the past 30 days 54 | var thisMonthMessages = filteredMessages.Where(x => x.Date.Ticks > DateTime.UtcNow.Subtract(new TimeSpan(30, 0, 0, 0)).Ticks) 55 | .Aggregate((long)0, (totalCount, nextCount) => totalCount + nextCount.MessageCount); 56 | 57 | // Filters the past 60 days, but not the past 30 58 | var lastMonthMessages = filteredMessages.Where(x => x.Date.Ticks < DateTime.UtcNow.Subtract(new TimeSpan(30, 0, 0, 0)).Ticks 59 | && x.Date.Ticks > DateTime.UtcNow.Subtract(new TimeSpan(60, 0, 0, 0)).Ticks) 60 | .Aggregate((long)0, (totalCount, nextCount) => totalCount + nextCount.MessageCount); 61 | 62 | if (lastMonthMessages == 0) 63 | { 64 | // Can't divide by zero 65 | return new DiscordEmbedBuilder 66 | { 67 | Description = "No data from last month to compare", 68 | Color = DiscordColor.Orange 69 | }; 70 | } 71 | 72 | if (thisMonthMessages == 0) 73 | { 74 | return new DiscordEmbedBuilder 75 | { 76 | Description = "No data from this month to compare", 77 | Color = DiscordColor.Orange 78 | }; 79 | } 80 | 81 | var delta = (decimal)thisMonthMessages / lastMonthMessages; 82 | 83 | string deltaText; 84 | if (delta == 1) 85 | { 86 | deltaText = "stayed exactly the same as last month"; 87 | } 88 | else if (delta < 1) 89 | { 90 | deltaText = $"gone down by {1 - delta:P2} this month"; 91 | } 92 | else 93 | { 94 | deltaText = $"gone up by {delta - 1:P2} this month"; 95 | } 96 | 97 | return new DiscordEmbedBuilder 98 | { 99 | Description = $"{mention}'s activity has {deltaText} ({thisMonthMessages} vs {lastMonthMessages})", 100 | Color = MomentumColor.Blue 101 | }; 102 | } 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Moderator/StatsModule.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using DSharpPlus; 5 | using DSharpPlus.SlashCommands; 6 | using DSharpPlus.Entities; 7 | using MomentumDiscordBot.Constants; 8 | using MomentumDiscordBot.Models; 9 | using MomentumDiscordBot.Utilities; 10 | 11 | namespace MomentumDiscordBot.Commands.Moderator 12 | { 13 | [SlashCommandGroup("stats", "stats commands")] 14 | public class StatsModule : ModeratorModuleBase 15 | { 16 | public Configuration Config { get; set; } 17 | 18 | [SlashCommand("user", "show user stats")] 19 | public async Task UserStatsAsync(InteractionContext context, [Option("member", "member")] DiscordUser user) 20 | { 21 | await context.DeferAsync(); 22 | 23 | var member = (DiscordMember)user; 24 | var totalMessageCount = await StatsUtility.GetTotalMessageCount(Config); 25 | var userStats = await StatsUtility.GetMessages(Config, x => x.UserId == member.Id); 26 | 27 | var embedBuilder = new DiscordEmbedBuilder 28 | { 29 | Title = "User Stats", 30 | Color = MomentumColor.Blue 31 | }.WithAuthor(member.DisplayName, iconUrl: member.AvatarUrl ?? member.DefaultAvatarUrl) 32 | .AddField("Total Messages", 33 | $"{userStats.Sum(x => x.MessageCount)} - {(decimal) userStats.Sum(x => x.MessageCount) / totalMessageCount:P} of total" 34 | ) 35 | .AddField("Top Channels", userStats 36 | .GroupBy(x => x.ChannelId) 37 | .Select(x => new {Id = x.Key, MessageCount = x.Sum(x => x.MessageCount)}) 38 | .OrderByDescending(x => x.MessageCount) 39 | .Take(5) 40 | .Aggregate("", (currentString, nextChannel) 41 | => currentString + Environment.NewLine + 42 | $"{context.Client.FindChannel(nextChannel.Id).Mention} - {nextChannel.MessageCount} messages")); 43 | 44 | await context.EditResponseAsync(new DiscordWebhookBuilder().AddEmbed(embedBuilder)); 45 | } 46 | 47 | [SlashCommand("channel", "show channel stats")] 48 | public async Task ChannelStatsAsync(InteractionContext context, [Option("channel", "channel")] DiscordChannel channel) 49 | { 50 | await context.DeferAsync(); 51 | 52 | if (channel.Type != ChannelType.Text) 53 | { 54 | await ReplyNewEmbedAsync(context, "Channel must be a text channel.", DiscordColor.Orange); 55 | return; 56 | } 57 | 58 | var totalMessageCount = await StatsUtility.GetTotalMessageCount(Config); 59 | var channelStats = await StatsUtility.GetMessages(Config, x => x.ChannelId == channel.Id); 60 | 61 | var embedBuilder = new DiscordEmbedBuilder 62 | { 63 | Title = $"#{channel.Name} Stats", 64 | Color = MomentumColor.Blue 65 | }.AddField("Total Messages", 66 | $"{channelStats.Sum(x => x.MessageCount)} - {(decimal) channelStats.Sum(x => x.MessageCount) / totalMessageCount:P} of total") 67 | .AddField("Top Users", channelStats 68 | .GroupBy(x => x.UserId) 69 | .Select(x => new {Id = x.Key, MessageCount = x.Sum(x => x.MessageCount)}) 70 | .OrderByDescending(x => x.MessageCount) 71 | .Take(5) 72 | .Aggregate("", (currentString, nextUser) 73 | => currentString + Environment.NewLine + 74 | $"{context.Guild.Members.Values.FirstOrDefault(x => x.Id == nextUser.Id)?.Mention ?? nextUser.Id.ToString()} - {nextUser.MessageCount} messages")); 75 | 76 | await context.EditResponseAsync(new DiscordWebhookBuilder().AddEmbed(embedBuilder)); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/Moderator/StatsTopModule.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using System.Threading.Tasks; 3 | using DSharpPlus.Entities; 4 | using DSharpPlus.SlashCommands; 5 | using MomentumDiscordBot.Models; 6 | using MomentumDiscordBot.Utilities; 7 | 8 | namespace MomentumDiscordBot.Commands.Moderator 9 | { 10 | [SlashCommandGroup("statstop", "shows stats of top ...")] 11 | public class StatsTopModule : ModeratorModuleBase 12 | { 13 | public Configuration Config { get; set; } 14 | 15 | [SlashCommand("users", "shows stats of top users")] 16 | public async Task TopUsersAsync(InteractionContext context) 17 | { 18 | await context.DeferAsync(); 19 | 20 | var topUsers = await StatsUtility.GetTopMessages(Config, x => x.UserId); 21 | 22 | var embedBuilder = topUsers.GetTopStatsEmbedBuilder("Most Active Users", 23 | x => 24 | $"{context.Guild.Members.Values.FirstOrDefault(y => y.Id == x.Grouping)?.Mention ?? x.Grouping.ToString()} - {x.MessageCount} messages"); 25 | 26 | await context.EditResponseAsync(new DiscordWebhookBuilder().AddEmbed(embedBuilder)); 27 | } 28 | 29 | [SlashCommand("channels", "shows stats of top channels")] 30 | public async Task TopChannelsAsync(InteractionContext context) 31 | { 32 | await context.DeferAsync(); 33 | 34 | var topChannels = await StatsUtility.GetTopMessages(Config, x => x.ChannelId); 35 | 36 | var embedBuilder = topChannels.GetTopStatsEmbedBuilder("Most Active Channels", 37 | x => 38 | $"{context.Guild.Channels.Values.FirstOrDefault(y => y.Id == x.Grouping)?.Mention ?? x.Grouping.ToString()} - {x.MessageCount} messages"); 39 | 40 | await context.EditResponseAsync(new DiscordWebhookBuilder().AddEmbed(embedBuilder)); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Commands/MomentumModuleBase.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using DSharpPlus; 3 | using DSharpPlus.SlashCommands; 4 | using DSharpPlus.Entities; 5 | using Serilog; 6 | 7 | namespace MomentumDiscordBot.Commands 8 | { 9 | [SlashModuleLifespan(SlashModuleLifespan.Transient)] 10 | public class MomentumModuleBase : ApplicationCommandModule 11 | { 12 | public ILogger Logger { get; set; } 13 | 14 | protected static async Task ReplyNewEmbedAsync(InteractionContext context, string text, DiscordColor color, bool ephemeral = false) 15 | { 16 | await ReplyNewEmbedAsync(context.Interaction, text, color, ephemeral); 17 | } 18 | 19 | protected static async Task ReplyNewEmbedAsync(DiscordInteraction inter, string text, DiscordColor color, bool ephemeral = false) 20 | { 21 | var embed = new DiscordEmbedBuilder 22 | { 23 | Description = text, 24 | Color = color 25 | }.Build(); 26 | 27 | await ReplyNewEmbedAsync(inter, embed, ephemeral); 28 | } 29 | 30 | protected static async Task ReplyNewEmbedAsync(DiscordInteraction inter, DiscordEmbed embed, bool ephemeral = false) 31 | { 32 | await inter.CreateResponseAsync(InteractionResponseType.ChannelMessageWithSource, new DiscordInteractionResponseBuilder().AddEmbed(embed: embed).AsEphemeral(ephemeral)); 33 | } 34 | 35 | protected static async Task<DiscordMessage> SlashReplyNewEmbedAsync(InteractionContext context, [Option("text", "text")] string text, [Option("color", "color")] DiscordColor color) 36 | { 37 | await context.CreateResponseAsync(InteractionResponseType.DeferredChannelMessageWithSource); 38 | 39 | var embed = new DiscordEmbedBuilder 40 | { 41 | Description = text, 42 | Color = color 43 | }.Build(); 44 | 45 | return await context.EditResponseAsync(new DiscordWebhookBuilder() 46 | .AddEmbed(embed)); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Constants/MomentumColor.cs: -------------------------------------------------------------------------------- 1 | using DSharpPlus.Entities; 2 | 3 | namespace MomentumDiscordBot.Constants 4 | { 5 | public static class MomentumColor 6 | { 7 | public static DiscordColor Gray => new(55, 55, 55); 8 | public static DiscordColor DarkGray => new(42, 42, 42); 9 | public static DiscordColor DarkestGray => new(32, 32, 32); 10 | 11 | public static DiscordColor LightGray => new(65, 65, 65); 12 | public static DiscordColor LighterGray => new(79, 79, 79); 13 | public static DiscordColor LightererGray => new(95, 95, 95); 14 | public static DiscordColor LighterererGray => new(130, 130, 130); 15 | public static DiscordColor LightestGray => new(200, 200, 200); 16 | 17 | public static DiscordColor Red => new(255, 106, 106); 18 | public static DiscordColor Green => new(153, 255, 153); 19 | public static DiscordColor Blue => new(24, 150, 211); 20 | public static DiscordColor GrayBlue => new(76, 139, 180); 21 | } 22 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Constants/PathConstants.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | 4 | namespace MomentumDiscordBot.Constants 5 | { 6 | internal static class PathConstants 7 | { 8 | private static readonly string ConfigFolderPath = Path.Combine(Environment.CurrentDirectory, "config"); 9 | internal static readonly string ConfigFilePath = Path.Combine(ConfigFolderPath, "config.json"); 10 | 11 | private static readonly string DbFolderPath = Path.Combine(Environment.CurrentDirectory, "data"); 12 | internal static readonly string DbFilePath = Path.Combine(DbFolderPath, "bot_data.db"); 13 | } 14 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Dockerfile: -------------------------------------------------------------------------------- 1 | #See https://aka.ms/containerfastmode to understand how Visual Studio uses this Dockerfile to build your images for faster debugging. 2 | 3 | FROM mcr.microsoft.com/dotnet/runtime:6.0 AS base 4 | WORKDIR /app 5 | 6 | FROM mcr.microsoft.com/dotnet/sdk:6.0 AS build 7 | WORKDIR /src 8 | COPY ["MomentumDiscordBot/MomentumDiscordBot.csproj", "MomentumDiscordBot/"] 9 | COPY ["nuget.config", "MomentumDiscordBot/"] 10 | RUN dotnet restore "MomentumDiscordBot/MomentumDiscordBot.csproj" 11 | COPY . . 12 | WORKDIR "/src/MomentumDiscordBot" 13 | RUN dotnet build "MomentumDiscordBot.csproj" -c Release -o /app/build 14 | 15 | FROM build AS publish 16 | RUN dotnet publish "MomentumDiscordBot.csproj" -c Release -o /app/publish 17 | 18 | FROM base AS final 19 | WORKDIR /app 20 | COPY --from=publish /app/publish . 21 | ENTRYPOINT ["dotnet", "MomentumDiscordBot.dll"] 22 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Models/Configuration.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | using System.Threading.Tasks; 6 | using DSharpPlus.Entities; 7 | using MomentumDiscordBot.Constants; 8 | using System.Collections.Concurrent; 9 | 10 | namespace MomentumDiscordBot.Models 11 | { 12 | public class CustomCommand 13 | { 14 | public CustomCommand() 15 | { } 16 | public CustomCommand(string title, string description, string user) : this(title, description, null, null, null, null, user) 17 | { } 18 | 19 | public CustomCommand(string title, string description, string buttonUrl, string buttonLabel, string thumbnailUrl, string imageUrl, string user) 20 | { 21 | this.Title = title; 22 | this.Description = description; 23 | this.ButtonUrl = buttonUrl; 24 | this.ButtonLabel = buttonLabel; 25 | this.ThumbnailUrl = thumbnailUrl; 26 | this.ImageUrl = imageUrl; 27 | this.User = user; 28 | this.CreationTimestamp = DateTime.Now; 29 | } 30 | [JsonPropertyName("title")] public string Title { get; set; } 31 | [JsonPropertyName("description")] public string Description { get; set; } 32 | [JsonPropertyName("button_url")] public string ButtonUrl { get; set; } 33 | [JsonPropertyName("button_label")] public string ButtonLabel { get; set; } 34 | [JsonPropertyName("thumbnail_url")] public string ThumbnailUrl { get; set; } 35 | [JsonPropertyName("image_url")] public string ImageUrl { get; set; } 36 | [JsonPropertyName("user")] public string User { get; set; } 37 | 38 | [Hidden] 39 | [JsonPropertyName("creation_timestamp")] public DateTime CreationTimestamp { get; set; } 40 | } 41 | public class Configuration 42 | { 43 | public Configuration() 44 | { 45 | CustomCommands = new ConcurrentDictionary<string, CustomCommand>(); 46 | } 47 | [JsonPropertyName("environment")] public string Environment { get; set; } 48 | [JsonPropertyName("bot_token")] public string BotToken { get; set; } 49 | [JsonPropertyName("guild_id")] public ulong GuildID { get; set; } 50 | 51 | [JsonPropertyName("twitch_api_client_id")] 52 | public string TwitchApiClientId { get; set; } 53 | 54 | [JsonPropertyName("twitch_api_token")] public string TwitchApiToken { get; set; } 55 | [JsonPropertyName("admin_id")] public ulong AdminRoleID { get; set; } 56 | 57 | [JsonPropertyName("livestream_mention_role_id")] 58 | public ulong LivestreamMentionRoleId { get; set; } 59 | 60 | [JsonPropertyName("mention_role_emoji")] 61 | public string MentionRoleEmojiString { get; set; } 62 | 63 | [JsonPropertyName("faq_role_emoji")] public string FaqRoleEmojiString { get; set; } 64 | [JsonPropertyName("mention_roles")] public ulong[] MentionRoles { get; set; } 65 | [JsonPropertyName("moderator_id")] public ulong ModeratorRoleID { get; set; } 66 | [JsonPropertyName("streamer_channel")] public ulong MomentumModStreamerChannelId { get; set; } 67 | [JsonPropertyName("roles_channel")] public ulong RolesChannelId { get; set; } 68 | [JsonPropertyName("twitch_user_bans")] public string[] TwitchUserBans { get; set; } 69 | 70 | [JsonPropertyName("admin_bot_channel")] 71 | public ulong AdminBotChannel { get; set; } 72 | 73 | [JsonPropertyName("stream_update_interval")] 74 | public int StreamUpdateInterval { get; set; } 75 | 76 | [JsonPropertyName("join_log_channel")] public ulong JoinLogChannel { get; set; } 77 | 78 | [JsonPropertyName("message_history_channel")] 79 | public ulong MessageHistoryChannel { get; set; } 80 | 81 | [JsonPropertyName("new_account_emote")] 82 | public string NewUserEmoteString { get; set; } 83 | 84 | [JsonPropertyName("minimum_stream_viewers_announce")] 85 | public int MinimumStreamViewersAnnounce { get; set; } 86 | 87 | [JsonPropertyName("seq_address")] public string SeqAddress { get; set; } 88 | [JsonPropertyName("seq_token")] public string SeqToken { get; set; } 89 | [JsonPropertyName("faq_channel")] public ulong FaqChannelId { get; set; } 90 | [JsonPropertyName("faq_role")] public ulong FaqRoleId { get; set; } 91 | [JsonPropertyName("developer_id")] public ulong DeveloperID { get; set; } 92 | 93 | [JsonPropertyName("alt_account_emoji")] 94 | public string AltAccountEmojiString { get; set; } 95 | 96 | [Hidden] 97 | [JsonPropertyName("mysql_connection_string")] 98 | public string MySqlConnectionString { get; set; } 99 | 100 | [JsonPropertyName("media_verified_role")] 101 | public ulong MediaVerifiedRoleId { get; set; } 102 | 103 | [JsonPropertyName("media_blacklisted_role")] 104 | public ulong MediaBlacklistedRoleId { get; set; } 105 | 106 | [JsonPropertyName("media_minimum_days")] 107 | public int MediaMinimumDays { get; set; } 108 | 109 | [JsonPropertyName("media_minimum_messages")] 110 | public int MediaMinimumMessages { get; set; } 111 | 112 | [JsonIgnore] public DiscordEmoji MentionRoleEmoji => DiscordEmoji.FromUnicode(MentionRoleEmojiString); 113 | [JsonIgnore] public DiscordEmoji FaqRoleEmoji => DiscordEmoji.FromUnicode(FaqRoleEmojiString); 114 | [JsonIgnore] public DiscordEmoji AltAccountEmoji => DiscordEmoji.FromUnicode(AltAccountEmojiString); 115 | 116 | [JsonPropertyName("custom_commands")] 117 | public ConcurrentDictionary<string, CustomCommand> CustomCommands { get; set; } 118 | public static async Task<Configuration> LoadFromFileAsync() 119 | { 120 | if (!File.Exists(PathConstants.ConfigFilePath)) 121 | { 122 | throw new FileNotFoundException( 123 | $"No config file exists, expected it at: '{PathConstants.ConfigFilePath}'"); 124 | } 125 | 126 | // File exists, get the config 127 | await using var fileStream = File.OpenRead(PathConstants.ConfigFilePath); 128 | return await JsonSerializer.DeserializeAsync<Configuration>(fileStream); 129 | } 130 | 131 | public async Task SaveToFileAsync() 132 | { 133 | await using var fileStream = File.Open(PathConstants.ConfigFilePath, FileMode.Create, FileAccess.Write, FileShare.None); 134 | 135 | await JsonSerializer.SerializeAsync(fileStream, this, new JsonSerializerOptions 136 | { 137 | WriteIndented = true 138 | }); 139 | } 140 | } 141 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Models/Data/DailyMessageCount.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel.DataAnnotations; 3 | using System.ComponentModel.DataAnnotations.Schema; 4 | 5 | namespace MomentumDiscordBot.Models.Data 6 | { 7 | [Table("message_count")] 8 | public class DailyMessageCount 9 | { 10 | [Required] 11 | [Column(TypeName = "BIGINT UNSIGNED NOT NULL")] 12 | public ulong UserId { get; set; } 13 | 14 | [Required] 15 | [Column(TypeName = "BIGINT UNSIGNED NOT NULL")] 16 | public ulong ChannelId { get; set; } 17 | 18 | [Required] 19 | [Column(TypeName = "DATE NOT NULL")] 20 | public DateTime Date { get; set; } 21 | 22 | [Column(TypeName = "MEDIUMINT UNSIGNED")] 23 | public uint MessageCount { get; set; } 24 | } 25 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Models/Data/MomentumDiscordDbContext.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace MomentumDiscordBot.Models.Data 4 | { 5 | public class MomentumDiscordDbContext : DbContext 6 | { 7 | public MomentumDiscordDbContext(DbContextOptions<MomentumDiscordDbContext> options) : base(options) { } 8 | 9 | public DbSet<DailyMessageCount> DailyMessageCount { get; set; } 10 | 11 | protected override void OnModelCreating(ModelBuilder modelBuilder) 12 | { 13 | modelBuilder.Entity<DailyMessageCount>() 14 | .HasKey(x => new {x.UserId, x.ChannelId, x.Date}); 15 | } 16 | } 17 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Models/HiddenAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace MomentumDiscordBot.Models 4 | { 5 | [AttributeUsage(AttributeTargets.Property)] 6 | public class HiddenAttribute : Attribute { } 7 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Models/MicroserviceAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace MomentumDiscordBot.Models 4 | { 5 | [AttributeUsage(AttributeTargets.Class)] 6 | public class MicroserviceAttribute : Attribute 7 | { 8 | public MicroserviceAttribute(MicroserviceType microserviceType = MicroserviceType.Manual) => 9 | Type = microserviceType; 10 | 11 | public MicroserviceType Type { get; } 12 | } 13 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Models/MicroserviceType.cs: -------------------------------------------------------------------------------- 1 | namespace MomentumDiscordBot.Models 2 | { 3 | public enum MicroserviceType 4 | { 5 | /// <summary> 6 | /// Does nothing with the service automatically 7 | /// </summary> 8 | Manual = 0, 9 | 10 | /// <summary> 11 | /// Adds the service to the DI provider 12 | /// </summary> 13 | Inject = 1, 14 | 15 | /// <summary> 16 | /// Initializes the services through `IServiceProvider#GetRequiredService()` 17 | /// </summary> 18 | InjectAndInitialize = 1 << 1 19 | } 20 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/MomentumDiscordBot.csproj: -------------------------------------------------------------------------------- 1 | <Project Sdk="Microsoft.NET.Sdk"> 2 | 3 | <PropertyGroup> 4 | <OutputType>Exe</OutputType> 5 | <TargetFramework>net6.0</TargetFramework> 6 | <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS> 7 | </PropertyGroup> 8 | 9 | <ItemGroup> 10 | <PackageReference Include="DSharpPlus" Version="4.5.0" /> 11 | <PackageReference Include="DSharpPlus.SlashCommands" Version="4.5.0" /> 12 | <PackageReference Include="DSharpPlus.Interactivity" Version="4.5.0" /> 13 | <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.1" /> 14 | <PackageReference Include="Microsoft.Extensions.Configuration" Version="7.0.0" /> 15 | <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" /> 16 | <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" /> 17 | <PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.11.1" /> 18 | <PackageReference Include="Serilog" Version="2.10.0" /> 19 | <PackageReference Include="Serilog.Extensions.Logging" Version="3.0.1" /> 20 | <PackageReference Include="Serilog.Sinks.Console" Version="4.0.0" /> 21 | <PackageReference Include="Serilog.Sinks.Seq" Version="5.0.1" /> 22 | <PackageReference Include="TwitchLib.Api" Version="3.8.0" /> 23 | </ItemGroup> 24 | 25 | </Project> 26 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using MomentumDiscordBot.Models; 4 | using Serilog; 5 | using Serilog.Debugging; 6 | using Serilog.Events; 7 | 8 | namespace MomentumDiscordBot 9 | { 10 | public static class Program 11 | { 12 | internal static void Main() 13 | => MainAsync().ConfigureAwait(false).GetAwaiter().GetResult(); 14 | 15 | private static async Task MainAsync() 16 | { 17 | var config = await Configuration.LoadFromFileAsync(); 18 | 19 | SelfLog.Enable(Console.WriteLine); 20 | 21 | var loggerConfig = new LoggerConfiguration() 22 | .MinimumLevel.Debug() 23 | .WriteTo.Console(); 24 | 25 | if (!string.IsNullOrEmpty(config.SeqAddress)) 26 | { 27 | loggerConfig 28 | .WriteTo.Seq(config.SeqAddress, LogEventLevel.Information, apiKey: config.SeqToken); 29 | } 30 | 31 | using var logger = loggerConfig 32 | .Enrich.WithProperty("Environment", config.Environment) 33 | .Enrich.WithProperty("Application", "Discord Bot") 34 | .CreateLogger(); 35 | 36 | var bot = new Bot(config, logger); 37 | await bot.StartAsync(); 38 | 39 | await Task.Delay(-1); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "MomentumDiscordBot": { 4 | "commandName": "Project" 5 | }, 6 | "Docker": { 7 | "commandName": "Docker" 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Services/DiscordEventService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using DSharpPlus; 5 | using DSharpPlus.Entities; 6 | using DSharpPlus.EventArgs; 7 | using MomentumDiscordBot.Models; 8 | using MomentumDiscordBot.Utilities; 9 | 10 | namespace MomentumDiscordBot.Services 11 | { 12 | [Microservice(MicroserviceType.InjectAndInitialize)] 13 | public class DiscordEventService 14 | { 15 | private readonly Configuration _config; 16 | private readonly DiscordClient _discordClient; 17 | private DiscordChannel _joinLogChannel; 18 | 19 | public DiscordEventService(DiscordClient discordClient, Configuration config) 20 | { 21 | _discordClient = discordClient; 22 | _config = config; 23 | 24 | _discordClient.GuildDownloadCompleted += DiscordClient_GuildDownloadCompleted; 25 | _discordClient.GuildMemberAdded += DiscordClient_UserJoined; 26 | } 27 | 28 | private Task DiscordClient_GuildDownloadCompleted(DiscordClient sender, GuildDownloadCompletedEventArgs e) 29 | { 30 | _ = Task.Run(async () => 31 | { 32 | foreach (var (_, guild) in _discordClient.Guilds) 33 | { 34 | await guild.RequestMembersAsync(presences: true, nonce: Environment.TickCount.ToString()); 35 | } 36 | }); 37 | 38 | return Task.CompletedTask; 39 | } 40 | 41 | private Task DiscordClient_UserJoined(DiscordClient sender, GuildMemberAddEventArgs e) 42 | { 43 | _ = Task.Run(async () => 44 | { 45 | // Haven't set the config 46 | if (_config.JoinLogChannel == default) 47 | { 48 | return; 49 | } 50 | 51 | _joinLogChannel ??= _discordClient.FindChannel(_config.JoinLogChannel); 52 | 53 | // Invalid channel ID 54 | if (_joinLogChannel == null) 55 | { 56 | return; 57 | } 58 | 59 | var accountAge = DateTimeOffset.UtcNow - e.Member.CreationTimestamp.UtcDateTime; 60 | var userJoinedMessage = await _joinLogChannel.SendMessageAsync( 61 | $"{e.Member.Mention} {Formatter.Sanitize(e.Member.Username.RemoveControlChars())}#{e.Member.Discriminator} joined, account was created {accountAge.ToPrettyFormat()} ago"); 62 | 63 | await WarnIfNewAccountAsync(userJoinedMessage, accountAge); 64 | await WarnIfDuplicatedNewAccountAsync(e.Member); 65 | }); 66 | 67 | return Task.CompletedTask; 68 | } 69 | 70 | private async Task WarnIfNewAccountAsync(DiscordMessage userJoinedMessage, TimeSpan accountAge) 71 | { 72 | if (accountAge.TotalHours <= 24) 73 | { 74 | // New user, add the emoji warning 75 | await userJoinedMessage.CreateReactionAsync(DiscordEmoji.FromName(_discordClient, 76 | _config.NewUserEmoteString)); 77 | } 78 | } 79 | 80 | private async Task WarnIfDuplicatedNewAccountAsync(DiscordUser member) 81 | { 82 | var messages = await _joinLogChannel.GetMessagesAsync(); 83 | 84 | // Find a matching user in the recent history 85 | var altAccount = messages 86 | .FromSelf(_discordClient) 87 | .OrderByDescending(x => x.Timestamp) 88 | // Parse the username from the bot's message, and make sure it has the new user emote 89 | .FirstOrDefault(x => 90 | x.Reactions.Any(x => 91 | x.Emoji == DiscordEmoji.FromName(_discordClient, _config.NewUserEmoteString)) && 92 | x.Content.Split(' ', 3)[1].Split('#')[0] == member.Username); 93 | 94 | // Is there a matching account 95 | if (altAccount != null) 96 | { 97 | await altAccount.DeleteReactionsEmojiAsync( 98 | DiscordEmoji.FromName(_discordClient, _config.NewUserEmoteString)); 99 | await altAccount.CreateReactionAsync(_config.AltAccountEmoji); 100 | } 101 | } 102 | } 103 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Services/InteractivityService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using DSharpPlus; 3 | using DSharpPlus.Interactivity; 4 | using DSharpPlus.Interactivity.Enums; 5 | using DSharpPlus.Interactivity.Extensions; 6 | using DSharpPlus.Entities; 7 | using MomentumDiscordBot.Models; 8 | 9 | namespace MomentumDiscordBot.Services 10 | { 11 | [Microservice(MicroserviceType.InjectAndInitialize)] 12 | public class InteractivityService 13 | { 14 | private readonly Configuration _config; 15 | private readonly DiscordClient _discordClient; 16 | 17 | public InteractivityService(Configuration config, DiscordClient discordClient, IServiceProvider services) 18 | { 19 | discordClient.UseInteractivity(new InteractivityConfiguration() 20 | { 21 | Timeout = TimeSpan.FromSeconds(10), 22 | ResponseBehavior = InteractionResponseBehavior.Respond, 23 | ResponseMessage = "Sorry, but this wasn't a valid option, or does not belong to you!", 24 | }); 25 | 26 | _config = config; 27 | _discordClient = discordClient; 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Services/MessageHistoryService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using DSharpPlus; 3 | using DSharpPlus.Entities; 4 | using DSharpPlus.EventArgs; 5 | using MomentumDiscordBot.Constants; 6 | using MomentumDiscordBot.Models; 7 | using MomentumDiscordBot.Utilities; 8 | 9 | namespace MomentumDiscordBot.Services 10 | { 11 | [Microservice(MicroserviceType.InjectAndInitialize)] 12 | public class MessageHistoryService 13 | { 14 | private readonly Configuration _config; 15 | private readonly DiscordClient _discordClient; 16 | private DiscordChannel _textChannel; 17 | 18 | public MessageHistoryService(DiscordClient discordClient, Configuration config) 19 | { 20 | _discordClient = discordClient; 21 | _config = config; 22 | 23 | _discordClient.GuildDownloadCompleted += DiscordClient_GuildsDownloaded; 24 | _discordClient.MessageDeleted += DiscordClient_MessageDeleted; 25 | _discordClient.MessageUpdated += DiscordClient_MessageUpdated; 26 | _discordClient.MessagesBulkDeleted += DiscordClient_MessagesBulkDeleted; 27 | } 28 | 29 | private Task DiscordClient_MessagesBulkDeleted(DiscordClient sender, MessageBulkDeleteEventArgs e) 30 | { 31 | _ = Task.Run(async () => 32 | { 33 | foreach (var message in e.Messages) 34 | { 35 | await HandleDeletedMessageAsync(message, true); 36 | } 37 | }); 38 | 39 | return Task.CompletedTask; 40 | } 41 | 42 | private Task DiscordClient_GuildsDownloaded(DiscordClient sender, GuildDownloadCompletedEventArgs e) 43 | { 44 | var channel = _discordClient.FindChannel(_config.MessageHistoryChannel); 45 | if (channel.Type == ChannelType.Text) 46 | { 47 | _textChannel = channel; 48 | } 49 | 50 | return Task.CompletedTask; 51 | } 52 | 53 | private Task DiscordClient_MessageUpdated(DiscordClient sender, MessageUpdateEventArgs e) 54 | { 55 | _ = Task.Run(async () => 56 | { 57 | // Early exits + if an embed appears, it is just a rich URL 58 | if (_textChannel == null || e.Channel.Guild == null || e.Author == null || 59 | e.Author.IsSelf(_discordClient) 60 | || e.Message == e.MessageBefore && e.MessageBefore.Embeds.Count == 0 && e.Message.Embeds.Count != 0) 61 | { 62 | return; 63 | } 64 | 65 | if (e.MessageBefore != null) 66 | { 67 | var embedBuilder = new DiscordEmbedBuilder 68 | { 69 | Title = "Message Edited - Old Message Content", 70 | Color = MomentumColor.Blue 71 | } 72 | .WithDescription(Formatter.MaskedUrl("Jump to Message", e.MessageBefore.JumpLink)) 73 | .AddMessageContent(e.MessageBefore); 74 | 75 | await _textChannel.SendMessageAsync(embed: embedBuilder.Build()); 76 | } 77 | else 78 | { 79 | await _textChannel.SendMessageAsync("A message was updated, but it was not in cache. " + 80 | e.Message.JumpLink); 81 | } 82 | }); 83 | 84 | return Task.CompletedTask; 85 | } 86 | 87 | private Task DiscordClient_MessageDeleted(DiscordClient sender, MessageDeleteEventArgs e) 88 | { 89 | _ = Task.Run(async () => 90 | { 91 | await HandleDeletedMessageAsync(e.Message); 92 | }); 93 | 94 | return Task.CompletedTask; 95 | } 96 | 97 | private async Task HandleDeletedMessageAsync(DiscordMessage message, bool bulkDelete = false) 98 | { 99 | if (_textChannel == null || message != null && message.Channel.Guild == null) 100 | { 101 | return; 102 | } 103 | 104 | if (message != null) 105 | { 106 | if (message.Author?.IsBot ?? true) 107 | { 108 | return; 109 | } 110 | 111 | var embedBuilder = new DiscordEmbedBuilder 112 | { 113 | Title = bulkDelete ? "Message Purged" : "Message Deleted", 114 | Color = bulkDelete ? MomentumColor.Red : DiscordColor.Orange 115 | }.AddMessageContent(message); 116 | 117 | await _textChannel.SendMessageAsync(embed: embedBuilder.Build()); 118 | } 119 | else 120 | { 121 | await _textChannel.SendMessageAsync("A message was deleted, but it was not in cache."); 122 | } 123 | } 124 | } 125 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Services/SlashCommandService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using System.Threading.Tasks; 4 | using DSharpPlus; 5 | using DSharpPlus.SlashCommands; 6 | using DSharpPlus.SlashCommands.EventArgs; 7 | using DSharpPlus.Entities; 8 | using DSharpPlus.EventArgs; 9 | using MomentumDiscordBot.Constants; 10 | using MomentumDiscordBot.Models; 11 | using MomentumDiscordBot.Utilities; 12 | using MomentumDiscordBot.Commands.Admin; 13 | using Serilog; 14 | 15 | namespace MomentumDiscordBot.Services 16 | { 17 | [Microservice(MicroserviceType.InjectAndInitialize)] 18 | public class SlashCommandService 19 | { 20 | private readonly Configuration _config; 21 | private readonly DiscordClient _discordClient; 22 | private DiscordChannel _textChannel; 23 | private readonly ILogger _logger; 24 | 25 | public SlashCommandService(Configuration config, DiscordClient discordClient, IServiceProvider services, 26 | ILogger logger) 27 | { 28 | var commands = discordClient.UseSlashCommands(new SlashCommandsConfiguration 29 | { 30 | Services = services 31 | }); 32 | 33 | _config = config; 34 | _discordClient = discordClient; 35 | _logger = logger; 36 | 37 | commands.RegisterCommands(Assembly.GetEntryAssembly(), config.GuildID); 38 | 39 | commands.SlashCommandErrored += Commands_SlashCommandErrored; 40 | commands.ContextMenuErrored += Commands_ContextMenuErrored; 41 | discordClient.GuildDownloadCompleted += DiscordClient_GuildsDownloaded; 42 | } 43 | 44 | private Task Commands_SlashCommandErrored(SlashCommandsExtension sender, SlashCommandErrorEventArgs e) 45 | { 46 | return HandleException(e.Exception, e.Context); 47 | } 48 | 49 | private Task Commands_ContextMenuErrored(SlashCommandsExtension sender, ContextMenuErrorEventArgs e) 50 | { 51 | return HandleException(e.Exception, e.Context); 52 | } 53 | 54 | private Task HandleException(Exception exception, BaseContext context) 55 | { 56 | _ = Task.Run(async () => 57 | { 58 | var embedBuilder = new DiscordEmbedBuilder 59 | { 60 | Color = MomentumColor.Red 61 | }; 62 | 63 | string response = null; 64 | 65 | var isChecksFailedException = true; 66 | switch (exception) 67 | { 68 | case SlashExecutionChecksFailedException e: 69 | response = e.FailedChecks.ToCleanResponse(); 70 | break; 71 | case ContextMenuExecutionChecksFailedException e: 72 | response = e.FailedChecks.ToCleanResponse(); 73 | break; 74 | default: 75 | isChecksFailedException = false; 76 | break; 77 | } 78 | 79 | if (isChecksFailedException) 80 | { 81 | embedBuilder 82 | .WithTitle("Access Denied") 83 | .WithDescription(response); 84 | 85 | await context.CreateResponseAsync(embed: embedBuilder); 86 | } 87 | 88 | else 89 | { 90 | var message = 91 | $"{context.User.Username}#{context.User.Discriminator} tried executing '{context.CommandName ?? "<unknown command>"}' but it errored:\n\n{exception.GetType()}: {exception.Message}"; 92 | 93 | embedBuilder 94 | .WithTitle("Bot Error") 95 | .WithDescription(message); 96 | 97 | _logger.Error(exception, message); 98 | 99 | var botChannel = _discordClient.FindChannel(_config.AdminBotChannel); 100 | 101 | if (botChannel is null) return; 102 | 103 | try 104 | { 105 | await botChannel.SendMessageAsync(embedBuilder.Build()); 106 | } 107 | catch (Exception) 108 | { 109 | _logger.Error( 110 | "Tried posting an message error in admin channel, but it errored!"); 111 | } 112 | } 113 | }); 114 | 115 | return Task.CompletedTask; 116 | } 117 | 118 | private Task DiscordClient_GuildsDownloaded(DiscordClient sender, GuildDownloadCompletedEventArgs e) 119 | { 120 | _ = Task.Run(async () => 121 | { 122 | _textChannel = await _discordClient.GetChannelAsync(_config.AdminBotChannel); 123 | await FindRestartMessageAsync(); 124 | }); 125 | 126 | return Task.CompletedTask; 127 | } 128 | 129 | private async Task FindRestartMessageAsync() 130 | { 131 | // Filter only messages from this bot 132 | var existingMessages = (await _textChannel.GetMessagesAsync(50)).FromSelf(_discordClient); 133 | 134 | foreach (var message in existingMessages) 135 | { 136 | var (result, restartMessage) = await TryFindRestartMessageAsync(message); 137 | if (!result) continue; 138 | // restart message found 139 | if (restartMessage != null) 140 | { 141 | // restartMessage is null when we already responded 142 | 143 | var diff = DateTimeOffset.Now - restartMessage.Timestamp; 144 | var embed = new DiscordEmbedBuilder 145 | { 146 | Description = $"Restart complete! Took {diff.TotalSeconds:N2} seconds.", 147 | Color = MomentumColor.Blue 148 | }; 149 | await restartMessage.RespondAsync(embed: embed); 150 | } 151 | 152 | break; 153 | } 154 | } 155 | 156 | private static async Task<(bool result, DiscordMessage restartMessage)> TryFindRestartMessageAsync( 157 | DiscordMessage input) 158 | { 159 | var message = await input.Channel.GetMessageAsync(input.Id); 160 | 161 | bool isReply = false; 162 | if (message.ReferencedMessage != null) 163 | { 164 | message = message.ReferencedMessage; 165 | isReply = true; 166 | } 167 | 168 | if (message.Interaction is not { Name: AdminModule.ForcerestartCommandName }) return (false, null); 169 | 170 | return (true, isReply ? null : input); 171 | } 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Services/StreamMonitorService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using DSharpPlus; 7 | using DSharpPlus.Entities; 8 | using DSharpPlus.EventArgs; 9 | using MomentumDiscordBot.Models; 10 | using MomentumDiscordBot.Utilities; 11 | using Serilog; 12 | using TwitchLib.Api.Helix.Models.Streams.GetStreams; 13 | 14 | namespace MomentumDiscordBot.Services 15 | { 16 | /// <summary> 17 | /// Service to provide a list of current streamers playing Momentum Mod. 18 | /// </summary> 19 | [Microservice(MicroserviceType.InjectAndInitialize)] 20 | public class StreamMonitorService 21 | { 22 | private readonly Configuration _config; 23 | private readonly DiscordClient _discordClient; 24 | private readonly ILogger _logger; 25 | private readonly List<string> _streamSoftBanList = new(); 26 | 27 | public List<string> StreamSoftBanList => _streamSoftBanList; 28 | 29 | private readonly TimeSpan _updateInterval; 30 | private readonly SemaphoreSlim semaphoreSlimLock = new(1, 1); 31 | public readonly TwitchApiService TwitchApiService; 32 | 33 | // <StreamID, MessageID> 34 | private Dictionary<string, ulong> _cachedStreamsIds; 35 | 36 | private bool _discordClientConnected; 37 | private Timer _intervalFunctionTimer; 38 | private DiscordChannel _textChannel; 39 | 40 | public StreamMonitorService(DiscordClient discordClient, TwitchApiService twitchApiService, 41 | Configuration config, ILogger logger) 42 | { 43 | _config = config; 44 | _discordClient = discordClient; 45 | _logger = logger; 46 | 47 | TwitchApiService = twitchApiService; 48 | 49 | _updateInterval = TimeSpan.FromMinutes(_config.StreamUpdateInterval); 50 | _discordClient.GuildDownloadCompleted += DiscordClient_GuildsDownloaded; 51 | 52 | _discordClient.SocketOpened += (s, e) => 53 | { 54 | _discordClientConnected = true; 55 | return Task.CompletedTask; 56 | }; 57 | 58 | _discordClient.SocketClosed += (s, e) => 59 | { 60 | _discordClientConnected = false; 61 | return Task.CompletedTask; 62 | }; 63 | } 64 | 65 | private void UpdateTextChannel() 66 | { 67 | _textChannel = _discordClient.FindChannel(_config.MomentumModStreamerChannelId); 68 | } 69 | 70 | private Task DiscordClient_GuildsDownloaded(DiscordClient sender, GuildDownloadCompletedEventArgs e) 71 | { 72 | _ = Task.Run(async () => 73 | { 74 | UpdateTextChannel(); 75 | 76 | // Enter and lock the semaphore, incase this occurs simultaneously with updating streams 77 | await semaphoreSlimLock.WaitAsync(); 78 | var messages = (await _textChannel.GetMessagesAsync()).ToList(); 79 | await TryParseExistingEmbedsAsync(messages); 80 | semaphoreSlimLock.Release(); 81 | 82 | // When reconnects occur, this will stack update events 83 | // Therefore, dispose every time 84 | if (_intervalFunctionTimer != null) 85 | { 86 | await _intervalFunctionTimer.DisposeAsync(); 87 | } 88 | 89 | _intervalFunctionTimer = new Timer(UpdateCurrentStreamersAsync, null, TimeSpan.Zero, _updateInterval); 90 | }); 91 | 92 | return Task.CompletedTask; 93 | } 94 | 95 | public async void UpdateCurrentStreamersAsync(object state) 96 | { 97 | _logger.Verbose("Waiting to enter UpdateCurrentStreamersAsync..."); 98 | // Wait for the semaphore to unlock, then lock it 99 | await semaphoreSlimLock.WaitAsync(); 100 | _logger.Verbose("Entered UpdateCurrentStreamersAsync"); 101 | 102 | if (!_discordClientConnected) 103 | { 104 | semaphoreSlimLock.Release(); 105 | return; 106 | } 107 | 108 | var streams = await TwitchApiService.GetLiveMomentumModStreamersAsync(); 109 | 110 | // On error no need to continue 111 | if (streams == null) 112 | { 113 | semaphoreSlimLock.Release(); 114 | return; 115 | } 116 | 117 | UpdateTextChannel(); 118 | 119 | var messages = (await _textChannel.GetMessagesAsync()).ToList(); 120 | 121 | await DeleteBannedStreamsAsync(streams, messages); 122 | await UnSoftbanEndedStreamsAsync(streams, messages); 123 | RegisterSoftBans(messages); 124 | 125 | TwitchApiService.PreviousLivestreams = streams; 126 | 127 | // Filter out soft/hard banned streams 128 | var filteredStreams = streams.Where(x => !IsSoftBanned(x) && !IsHardBanned(x)).ToList(); 129 | 130 | // Reload embeds 131 | try 132 | { 133 | // If there is an exception when parsing the existing embeds, no need to continue 134 | // Return early when there are no streams as well, as no need to send/update 135 | if (!await TryParseExistingEmbedsAsync(messages) || filteredStreams.Count == 0) 136 | { 137 | semaphoreSlimLock.Release(); 138 | return; 139 | } 140 | 141 | await SendOrUpdateStreamEmbedsAsync(filteredStreams, messages); 142 | } 143 | catch (Exception e) 144 | { 145 | _logger.Error(e, "StreamMonitorService"); 146 | } 147 | 148 | semaphoreSlimLock.Release(); 149 | } 150 | 151 | private async Task SendOrUpdateStreamEmbedsAsync(List<Stream> filteredStreams, List<DiscordMessage> messages) 152 | { 153 | foreach (var stream in filteredStreams) 154 | { 155 | var (embed, messageText) = await GetStreamEmbed(stream); 156 | 157 | // New streams are not in the cache 158 | if (!IsStreamInCache(stream)) 159 | { 160 | // If the stream is not above the minimum viewers then ignore it, but we want to update a stream if it dips below 161 | if (stream.ViewerCount < _config.MinimumStreamViewersAnnounce) 162 | { 163 | continue; 164 | } 165 | 166 | // New stream, send a new message 167 | var roleMention = new RoleMention(_config.LivestreamMentionRoleId); 168 | var messageBuilder = new DiscordMessageBuilder() 169 | .WithContent(messageText) 170 | .WithEmbed(embed) 171 | .WithAllowedMention(roleMention); 172 | 173 | var message = 174 | await _textChannel.SendMessageAsync(messageBuilder); 175 | 176 | _cachedStreamsIds.Add(stream.Id, message.Id); 177 | } 178 | else 179 | { 180 | // Get the message id from the stream 181 | if (!_cachedStreamsIds.TryGetValue(stream.Id, out var messageId)) 182 | { 183 | _logger.Warning("StreamMonitorService: Could not message from cached stream ID"); 184 | continue; 185 | } 186 | 187 | // Existing stream, update message with new information 188 | var oldMessage = messages.FirstOrDefault(x => x.Id == messageId); 189 | 190 | if (oldMessage == null) 191 | { 192 | continue; 193 | } 194 | 195 | if (oldMessage.Author.IsSelf(_discordClient)) 196 | { 197 | await oldMessage.ModifyAsync(messageText, embed); 198 | } 199 | } 200 | } 201 | } 202 | 203 | private bool IsStreamInCache(Stream stream) => _cachedStreamsIds.ContainsKey(stream.Id); 204 | 205 | private async Task<KeyValuePair<DiscordEmbed, string>> GetStreamEmbed(Stream stream) 206 | { 207 | var mentionRole = _discordClient.FindRole(_config.LivestreamMentionRoleId); 208 | var messageText = 209 | $"{Formatter.Sanitize(stream.UserName)} has gone live! {mentionRole.Mention}"; 210 | 211 | var embed = new DiscordEmbedBuilder 212 | { 213 | Title = Formatter.Sanitize(stream.Title), 214 | Color = new DiscordColor(145, 70, 255), 215 | Author = new DiscordEmbedBuilder.EmbedAuthor 216 | { 217 | Name = stream.UserName, 218 | IconUrl = await TwitchApiService.GetStreamerIconUrlAsync(stream.UserId), 219 | Url = $"https://twitch.tv/{stream.UserLogin}" 220 | }, 221 | ImageUrl = stream.ThumbnailUrl.Replace("{width}", "1280").Replace("{height}", "720") + "?q=" + 222 | Environment.TickCount, 223 | Url = $"https://twitch.tv/{stream.UserLogin}", 224 | Timestamp = DateTimeOffset.Now 225 | }.AddField("🔴 Viewers", stream.ViewerCount.ToString(), true) 226 | .AddField("🎦 Uptime", (DateTime.UtcNow - stream.StartedAt).ToPrettyFormat(2), true) 227 | .WithFooter("Streaming " + await TwitchApiService.GetGameNameAsync(stream.GameId)) 228 | .Build(); 229 | 230 | return new KeyValuePair<DiscordEmbed, string>(embed, messageText); 231 | } 232 | 233 | private bool IsHardBanned(Stream stream) => (_config.TwitchUserBans ?? Array.Empty<string>()).Contains(stream.UserId); 234 | 235 | private bool IsSoftBanned(Stream stream) => _streamSoftBanList.Contains(stream.Id); 236 | 237 | private void RegisterSoftBans(List<DiscordMessage> messages) 238 | { 239 | // Check for soft-banned stream, when a mod deletes the message 240 | try 241 | { 242 | var existingSelfMessages = 243 | messages.FromSelf(_discordClient); 244 | var softBannedMessages = _cachedStreamsIds.Where(x => existingSelfMessages.All(y => y.Id != x.Value)).ToList(); 245 | 246 | foreach (var softBannedMessage in softBannedMessages) 247 | { 248 | _logger.Information("Registered softban for streamer {streamId}", softBannedMessage.Key); 249 | } 250 | 251 | _streamSoftBanList.AddRange(softBannedMessages.Select(x => x.Key)); 252 | } 253 | catch (Exception e) 254 | { 255 | _logger.Warning(e, "StreamMonitorService"); 256 | } 257 | } 258 | 259 | private async Task UnSoftbanEndedStreamsAsync(IEnumerable<Stream> streams, List<DiscordMessage> messages) 260 | { 261 | // If the cached stream id's isn't in the fetched stream id, it is an ended stream 262 | var streamIds = streams.Select(x => x.Id); 263 | var endedStreams = _cachedStreamsIds.Where(x => !streamIds.Contains(x.Key)); 264 | 265 | foreach (var (endedStreamId, messageId) in endedStreams) 266 | { 267 | // If the stream was soft banned, remove it 268 | if (_streamSoftBanList.Contains(endedStreamId)) 269 | { 270 | _streamSoftBanList.Remove(endedStreamId); 271 | } 272 | 273 | try 274 | { 275 | var message = messages.FirstOrDefault(x => x.Id == messageId); 276 | 277 | if (message == null) 278 | { 279 | continue; 280 | } 281 | 282 | await _textChannel.DeleteMessageAsync(message); 283 | messages.Remove(message); 284 | } 285 | catch 286 | { 287 | _logger.Warning("StreamMonitorService: Tried to delete message " + messageId + 288 | " but it does not exist."); 289 | } 290 | 291 | _cachedStreamsIds.Remove(endedStreamId); 292 | } 293 | } 294 | 295 | private async Task DeleteBannedStreamsAsync(IEnumerable<Stream> streams, List<DiscordMessage> messages) 296 | { 297 | // Get streams from banned users 298 | if (_config.TwitchUserBans != null && _config.TwitchUserBans.Length > 0) 299 | { 300 | var bannedStreams = streams.Where(x => _config.TwitchUserBans.Contains(x.UserId)); 301 | 302 | foreach (var bannedStream in bannedStreams) 303 | { 304 | if (_cachedStreamsIds.TryGetValue(bannedStream.Id, out var messageId)) 305 | { 306 | var message = messages.FirstOrDefault(x => x.Id == messageId); 307 | 308 | if (message == null) 309 | { 310 | continue; 311 | } 312 | 313 | await _textChannel.DeleteMessageAsync(message); 314 | 315 | messages.Remove(message); 316 | } 317 | } 318 | } 319 | } 320 | 321 | private async Task<bool> TryParseExistingEmbedsAsync(List<DiscordMessage> messages) 322 | { 323 | // Reset cache 324 | _cachedStreamsIds = new Dictionary<string, ulong>(); 325 | 326 | // Get all messages 327 | messages = messages.FromSelf(_discordClient).ToList(); 328 | 329 | if (!messages.Any()) 330 | { 331 | return true; 332 | } 333 | 334 | var streams = await TwitchApiService.GetLiveMomentumModStreamersAsync(); 335 | 336 | // Error getting streams, don't continue 337 | if (streams == null) 338 | { 339 | return false; 340 | } 341 | 342 | // Delete existing bot messages simultaneously 343 | var deleteTasks = messages 344 | .Select(async message => 345 | { 346 | try 347 | { 348 | if (message.Embeds.Count == 1) 349 | { 350 | var matchingStream = 351 | streams.FirstOrDefault(y => y.UserName == message.Embeds[0].Author?.Name); 352 | if (matchingStream == null) 353 | { 354 | // No matching stream 355 | await message.DeleteAsync(); 356 | } 357 | else if (!_cachedStreamsIds.TryAdd(matchingStream.Id, message.Id)) 358 | { 359 | // Found the matching stream 360 | _logger.Warning("StreamMonitorService: Duplicate cached streamer: " + 361 | matchingStream.UserName + ", deleting..."); 362 | await message.DeleteAsync(); 363 | } 364 | } 365 | else 366 | { 367 | // Stream has ended, or failed to parse 368 | await message.DeleteAsync(); 369 | } 370 | } 371 | catch (Exception e) 372 | { 373 | _logger.Warning(e, "Could not delete message {message}", message); 374 | } 375 | }); 376 | 377 | await Task.WhenAll(deleteTasks); 378 | return true; 379 | } 380 | } 381 | } 382 | -------------------------------------------------------------------------------- /MomentumDiscordBot/Services/TwitchApiService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Threading.Tasks; 6 | using MomentumDiscordBot.Models; 7 | using Serilog; 8 | using TwitchLib.Api; 9 | using TwitchLib.Api.Helix.Models.Streams.GetStreams; 10 | 11 | namespace MomentumDiscordBot.Services 12 | { 13 | [Microservice(MicroserviceType.InjectAndInitialize)] 14 | public class TwitchApiService 15 | { 16 | private readonly TwitchAPI _apiService; 17 | 18 | private readonly ConcurrentDictionary<string, string> _categoryNames = new(); 19 | 20 | private readonly ILogger _logger; 21 | private string _momentumModGameId = null; 22 | 23 | public TwitchApiService(ILogger logger, Configuration config) 24 | { 25 | _logger = logger; 26 | 27 | _apiService = new TwitchAPI(); 28 | _apiService.Settings.ClientId = config.TwitchApiClientId; 29 | _apiService.Settings.Secret = config.TwitchApiToken; 30 | } 31 | 32 | public List<Stream> PreviousLivestreams { get; set; } 33 | 34 | public async Task<string> GetMomentumModIdAsync() 35 | { 36 | var games = await _apiService.Helix.Games.GetGamesAsync(gameNames: new List<string> { "Momentum Mod" }); 37 | _momentumModGameId = games.Games.First().Id; 38 | return _momentumModGameId; 39 | } 40 | 41 | public async Task<string> GetGameNameAsync(string id) 42 | { 43 | if (_categoryNames.TryGetValue(id, out var result)) 44 | { 45 | return result; 46 | } 47 | 48 | 49 | var game = await _apiService.Helix.Games.GetGamesAsync(new List<string> { id }); 50 | 51 | _categoryNames.TryAdd(id, game.Games.First().Name); 52 | return game.Games.First().Name; 53 | } 54 | 55 | public async Task<List<Stream>> GetLiveMomentumModStreamersAsync() 56 | { 57 | try 58 | { 59 | // Get the game ID once, then reuse it 60 | var streams = await _apiService.Helix.Streams.GetStreamsAsync(gameIds: new List<string> 61 | {_momentumModGameId ?? await GetMomentumModIdAsync()}); 62 | return streams.Streams.ToList(); 63 | } 64 | catch (Exception e) 65 | { 66 | _logger.Error(e, "TwitchApiService"); 67 | return null; 68 | } 69 | } 70 | 71 | public async Task<string> GetStreamerIconUrlAsync(string id) 72 | { 73 | try 74 | { 75 | var users = await _apiService.Helix.Users.GetUsersAsync(new List<string> { id }); 76 | 77 | // Selected through ID, should only return one 78 | var user = users.Users.First(); 79 | return user.ProfileImageUrl; 80 | } 81 | catch (Exception e) 82 | { 83 | _logger.Error(e, "TwitchApiService"); 84 | return string.Empty; 85 | } 86 | } 87 | 88 | public async Task<string> GetStreamerIDAsync(string name) 89 | { 90 | var response = await _apiService.Helix.Users.GetUsersAsync(logins: new List<string> { name }); 91 | var users = response.Users; 92 | 93 | if (users.Length == 0) 94 | { 95 | throw new Exception("No user was found for that input"); 96 | } 97 | 98 | if (users.Length > 1) 99 | { 100 | throw new Exception("More than one user was found for that input"); 101 | } 102 | 103 | return users.First().Id; 104 | } 105 | 106 | public async Task<string> GetStreamerNameAsync(string id) 107 | { 108 | var response = await _apiService.Helix.Users.GetUsersAsync(new List<string> { id }); 109 | var users = response.Users; 110 | 111 | if (users.Length == 0) 112 | { 113 | throw new Exception("No user was found for that input"); 114 | } 115 | 116 | if (users.Length > 1) 117 | { 118 | throw new Exception("More than one user was found for that input"); 119 | } 120 | 121 | return users.First().DisplayName; 122 | } 123 | 124 | public async Task<string> GetOrDownloadTwitchIDAsync(string username) 125 | { 126 | if (ulong.TryParse(username, out _)) 127 | { 128 | // Input is a explicit Twitch ID 129 | return username; 130 | } 131 | 132 | // Input is the Twitch username 133 | var cachedUser = PreviousLivestreams.FirstOrDefault(x => 134 | string.Equals(username, x.UserName, StringComparison.InvariantCultureIgnoreCase)); 135 | 136 | if (cachedUser != null) 137 | { 138 | // User is in the cache 139 | return cachedUser.UserId; 140 | } 141 | 142 | try 143 | { 144 | // Search the API, throws exception if not found 145 | return await GetStreamerIDAsync(username); 146 | } 147 | catch (Exception e) 148 | { 149 | _logger.Error(e, "TwitchApiService"); 150 | return null; 151 | } 152 | } 153 | } 154 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Services/UserTrustService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using DSharpPlus; 5 | using DSharpPlus.Entities; 6 | using DSharpPlus.EventArgs; 7 | using MomentumDiscordBot.Models; 8 | using MomentumDiscordBot.Models.Data; 9 | using MomentumDiscordBot.Utilities; 10 | 11 | namespace MomentumDiscordBot.Services 12 | { 13 | [Microservice(MicroserviceType.InjectAndInitialize)] 14 | public class UserTrustService 15 | { 16 | private readonly Configuration _config; 17 | private readonly DiscordClient _discordClient; 18 | 19 | public UserTrustService(DiscordClient discordClient, Configuration config) 20 | { 21 | _discordClient = discordClient; 22 | _config = config; 23 | 24 | _discordClient.MessageCreated += DiscordClient_MessageCreated; 25 | } 26 | 27 | private Task DiscordClient_MessageCreated(DiscordClient sender, MessageCreateEventArgs e) 28 | { 29 | _ = Task.Run(async () => 30 | { 31 | // Ignore bots or DMs 32 | if (e.Author.IsBot || e.Channel.IsPrivate) 33 | { 34 | return; 35 | } 36 | 37 | await using var dbContext = DbContextHelper.GetNewDbContext(_config); 38 | LogMessageCount(dbContext, e.Message); 39 | await CheckVerifiedRoleAsync(dbContext, e.Message); 40 | }); 41 | 42 | return Task.CompletedTask; 43 | } 44 | 45 | private static void LogMessageCount(MomentumDiscordDbContext dbContext, DiscordMessage message) 46 | { 47 | var user = dbContext.DailyMessageCount 48 | .SingleOrDefault(x => x.UserId == message.Author.Id && 49 | x.ChannelId == message.Channel.Id && 50 | x.Date == message.CreationTimestamp.UtcDateTime.Date); 51 | 52 | if (user != null) 53 | { 54 | // If they have a message count for that day, just increment 55 | user.MessageCount++; 56 | } 57 | else 58 | { 59 | // No data for the current state, make a new message count 60 | var newUser = new DailyMessageCount 61 | { 62 | ChannelId = message.Channel.Id, 63 | Date = message.CreationTimestamp.UtcDateTime.Date, 64 | UserId = message.Author.Id, 65 | MessageCount = 1 66 | }; 67 | 68 | dbContext.Add(newUser); 69 | } 70 | 71 | dbContext.SaveChanges(); 72 | } 73 | 74 | private async Task CheckVerifiedRoleAsync(MomentumDiscordDbContext dbContext, DiscordMessage message) 75 | { 76 | // If they already have the verified role, or they have the blacklist role, no need to check 77 | if (message.Author is not DiscordMember member) 78 | { 79 | return; 80 | } 81 | 82 | if (!member.Roles.Any(x => x.Id == _config.MediaVerifiedRoleId || x.Id == _config.MediaBlacklistedRoleId)) 83 | { 84 | // Have they been here for the minimum days 85 | var messagesFromUser = dbContext.DailyMessageCount.ToList() 86 | .Where(x => x.UserId == member.Id) 87 | .OrderBy(x => x.Date) 88 | .ToList(); 89 | 90 | if (!messagesFromUser.Any()) 91 | { 92 | // Haven't sent a message 93 | return; 94 | } 95 | 96 | // Has to be at least one message, because this runs after message received event 97 | var earliestMessage = messagesFromUser.First(); 98 | 99 | if ((DateTime.UtcNow - earliestMessage.Date).TotalDays > _config.MediaMinimumDays) 100 | { 101 | // They have been here minimum days, sum messages 102 | var messageCount = messagesFromUser.Sum(x => x.MessageCount); 103 | 104 | if (messageCount > _config.MediaMinimumMessages) 105 | { 106 | // User meets all the requirements 107 | var verifiedRole = member.Guild.GetRole(_config.MediaVerifiedRoleId); 108 | 109 | await member.GrantRoleAsync(verifiedRole); 110 | } 111 | } 112 | } 113 | } 114 | } 115 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Utilities/DateTimeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | 4 | namespace MomentumDiscordBot.Utilities 5 | { 6 | public static class DateTimeExtensions 7 | { 8 | public static string GetTimeStringSinceDateTime(this DateTime dateTime) 9 | { 10 | TimeSpan deltaTime; 11 | if (dateTime.Ticks < DateTime.Now.Ticks) 12 | { 13 | deltaTime = DateTime.Now - dateTime; 14 | return deltaTime.ToPrettyFormat() + " ago"; 15 | } 16 | 17 | deltaTime = dateTime - DateTime.Now; 18 | return deltaTime.ToPrettyFormat() + " in the future"; 19 | } 20 | 21 | public static string ToPrettyFormat(this TimeSpan span, int accuracy = 3) 22 | { 23 | if (span.TotalMilliseconds < 1) 24 | { 25 | return "instantaneously"; 26 | } 27 | 28 | var sb = new StringBuilder(); 29 | if (span.Days / 365 > 0) 30 | { 31 | var approximateYears = span.Days / 365; 32 | sb = sb.AppendFormat("{0} year{1} ", approximateYears, approximateYears > 1 ? "s" : string.Empty); 33 | } 34 | 35 | // Modulo 365 because we remove approximately each year above 36 | if (span.Days % 365 > 0) 37 | { 38 | sb = sb.AppendFormat("{0} day{1} ", span.Days % 365, span.Days % 365 > 1 ? "s" : string.Empty); 39 | } 40 | 41 | if (span.Hours > 0) 42 | { 43 | sb = sb.AppendFormat("{0} hour{1} ", span.Hours, span.Hours > 1 ? "s" : string.Empty); 44 | } 45 | 46 | if (span.Minutes > 0) 47 | { 48 | sb = sb.AppendFormat("{0} minute{1} ", span.Minutes, span.Minutes > 1 ? "s" : string.Empty); 49 | } 50 | 51 | if (span.Seconds > 0) 52 | { 53 | sb = sb.AppendFormat("{0} second{1} ", span.Seconds, span.Seconds > 1 ? "s" : string.Empty); 54 | } 55 | 56 | if (span.TotalSeconds < 1 && span.Milliseconds > 0) 57 | { 58 | sb = sb.AppendFormat("{0} millisecond{1} ", span.Milliseconds, 59 | span.Milliseconds > 1 ? "s" : string.Empty); 60 | } 61 | 62 | var output = sb.ToString(); 63 | 64 | // When years are taken, no need to show ms - use 3 levels of accuracy 65 | // 3 levels of accuracy * 2 spaces per level 66 | var thirdSpace = output.GetNthIndex(' ', accuracy * 2); 67 | 68 | if (thirdSpace == -1) 69 | { 70 | // Less than 3 levels of accuracy, just return the last part 71 | thirdSpace = output.Length; 72 | } 73 | 74 | return output[..thirdSpace].Trim(); 75 | } 76 | } 77 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Utilities/DbContextHelper.cs: -------------------------------------------------------------------------------- 1 | using System.IO; 2 | using Microsoft.EntityFrameworkCore; 3 | using MomentumDiscordBot.Constants; 4 | using MomentumDiscordBot.Models; 5 | using MomentumDiscordBot.Models.Data; 6 | 7 | namespace MomentumDiscordBot.Utilities 8 | { 9 | public static class DbContextHelper 10 | { 11 | public static MomentumDiscordDbContext GetNewDbContext(Configuration config) => 12 | new(new DbContextOptionsBuilder<MomentumDiscordDbContext>() 13 | .UseSqlite($"Data Source={PathConstants.DbFilePath}").Options); 14 | } 15 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Utilities/DiscordEmbedBuilderExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | using DSharpPlus.Entities; 3 | 4 | namespace MomentumDiscordBot.Utilities 5 | { 6 | public static class DiscordEmbedBuilderExtensions 7 | { 8 | public static DiscordEmbedBuilder AddMessageContent(this DiscordEmbedBuilder embedBuilder, 9 | DiscordMessage message) 10 | { 11 | if (message.Author != null) 12 | { 13 | embedBuilder.AddField("User", 14 | $"{message.Author.Mention} {message.Author}"); 15 | } 16 | 17 | if (message.Channel != null) 18 | { 19 | embedBuilder.AddField("Channel", message.Channel.Mention); 20 | } 21 | 22 | if (!string.IsNullOrWhiteSpace(message.Content)) 23 | { 24 | embedBuilder.AddField("Message", string.Join(string.Empty, message.Content.Take(1024))); 25 | 26 | if (message.Content.Length > 1024) 27 | { 28 | embedBuilder.AddField("Message Overflow", 29 | string.Join(string.Empty, message.Content.Skip(1024))); 30 | } 31 | } 32 | 33 | var attachments = message.Attachments.ToList(); 34 | for (var i = 0; i < attachments.Count; i++) 35 | { 36 | var attachment = attachments[i]; 37 | 38 | embedBuilder.AddField($"Attachment {i + 1}", attachment.Url); 39 | } 40 | 41 | return embedBuilder; 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Utilities/DiscordExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using DSharpPlus; 4 | using DSharpPlus.Entities; 5 | 6 | namespace MomentumDiscordBot.Utilities 7 | { 8 | public static class DiscordExtensions 9 | { 10 | private const Permissions DangerousGuildPermissions = Permissions.Administrator | Permissions.BanMembers | 11 | Permissions.DeafenMembers | Permissions.KickMembers | 12 | Permissions.ManageChannels | Permissions.ManageEmojis | 13 | Permissions.ManageGuild | Permissions.ManageMessages | 14 | Permissions.ManageNicknames | Permissions.ManageRoles | 15 | Permissions.ManageWebhooks | Permissions.MoveMembers | 16 | Permissions.MuteMembers | Permissions.ViewAuditLog | 17 | Permissions.UseExternalEmojis; 18 | 19 | public static IEnumerable<DiscordMessage> FromSelf(this IEnumerable<DiscordMessage> source, 20 | DiscordClient discordClient) 21 | => source.Where(x => x.Author.Id == discordClient.CurrentUser.Id); 22 | 23 | public static bool IsSelf(this DiscordUser user, DiscordClient discordClient) 24 | => discordClient.CurrentUser.Id == user.Id; 25 | 26 | public static bool RequireRole(this DiscordUser user, ulong roleId) 27 | { 28 | // Check if this user is a Guild User, which is the only context where roles exist 29 | if (user is not DiscordMember member) 30 | { 31 | return false; 32 | } 33 | 34 | return member.Roles.Any(role => role.Id == roleId); 35 | } 36 | 37 | public static Permissions GetDangerousPermissions(this Permissions guildPermissions) 38 | => DangerousGuildPermissions & guildPermissions; 39 | 40 | public static bool IsUserMessage(this DiscordMessage message) 41 | => message.Author != null && 42 | !message.Author.IsBot && 43 | (!message.Author.IsSystem ?? true) && 44 | message.MessageType.HasValue && 45 | message.MessageType == MessageType.Default; 46 | 47 | public static DiscordChannel FindChannel(this DiscordClient discordClient, ulong id) 48 | => discordClient.Guilds 49 | .SelectMany(x => x.Value.Channels.Values) 50 | .FirstOrDefault(x => x != null && x.Id == id); 51 | 52 | public static DiscordRole FindRole(this DiscordClient discordClient, ulong id) 53 | => discordClient.Guilds 54 | .SelectMany(x => x.Value.Roles.Values) 55 | .FirstOrDefault(x => x != null && x.Id == id); 56 | } 57 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Utilities/FailedChecksExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using DSharpPlus.SlashCommands; 5 | using MomentumDiscordBot.Commands.Checks; 6 | 7 | namespace MomentumDiscordBot.Utilities 8 | { 9 | public static class FailedChecksExtensions 10 | { 11 | private const string ReasonPrefix = " • "; 12 | 13 | public static string ToCleanResponse(this IEnumerable<SlashCheckBaseAttribute> failedChecks) 14 | { 15 | var reasons = failedChecks.Select(x => x.ToCleanReason()); 16 | 17 | return ReasonPrefix + string.Join(Environment.NewLine + ReasonPrefix, reasons); 18 | } 19 | 20 | public static string ToCleanResponse(this IEnumerable<ContextMenuCheckBaseAttribute> failedChecks) 21 | { 22 | var reasons = failedChecks.Select(x => x.ToCleanReason()); 23 | 24 | return ReasonPrefix + string.Join(Environment.NewLine + ReasonPrefix, reasons); 25 | } 26 | 27 | private static string ToCleanReason(this SlashCheckBaseAttribute check) 28 | { 29 | if (check is DescriptiveCheckBaseAttribute descriptiveCheck) 30 | { 31 | return descriptiveCheck.FailureResponse; 32 | } 33 | 34 | return check.ToString(); 35 | } 36 | private static string ToCleanReason(this ContextMenuCheckBaseAttribute check) 37 | { 38 | if (check is ContextMenuDescriptiveCheckBaseAttribute descriptiveCheck) 39 | { 40 | return descriptiveCheck.FailureResponse; 41 | } 42 | 43 | return check.ToString(); 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Utilities/MicroserviceExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reflection; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using MomentumDiscordBot.Models; 6 | 7 | namespace MomentumDiscordBot.Utilities 8 | { 9 | public static class MicroserviceExtensions 10 | { 11 | public static IServiceCollection InjectMicroservices(this IServiceCollection services, Assembly assembly) 12 | { 13 | var types = assembly.ExportedTypes.Where(type => 14 | { 15 | var typeInfo = type.GetTypeInfo(); 16 | 17 | // Does it have the `MicroserviceAttribute` 18 | return typeInfo.GetCustomAttributes().Any(x => x.GetType() == typeof(MicroserviceAttribute)); 19 | }).ToList(); 20 | 21 | foreach (var type in types) 22 | { 23 | var microserviceAttribute = type.GetCustomAttribute<MicroserviceAttribute>(); 24 | 25 | if (microserviceAttribute != null && (microserviceAttribute.Type == MicroserviceType.Inject || 26 | microserviceAttribute.Type == 27 | MicroserviceType.InjectAndInitialize)) 28 | { 29 | services.AddSingleton(type); 30 | } 31 | } 32 | 33 | return services; 34 | } 35 | 36 | public static void InitializeMicroservices(this IServiceProvider services, Assembly assembly) 37 | { 38 | var types = assembly.ExportedTypes.Where(type => 39 | { 40 | var typeInfo = type.GetTypeInfo(); 41 | 42 | // Does it have the `MicroserviceAttribute` 43 | return typeInfo.GetCustomAttributes().Any(x => x.GetType() == typeof(MicroserviceAttribute)); 44 | }); 45 | 46 | foreach (var type in types) 47 | { 48 | var microserviceAttribute = type.GetCustomAttribute<MicroserviceAttribute>(); 49 | 50 | if (microserviceAttribute != null && microserviceAttribute.Type == MicroserviceType.InjectAndInitialize) 51 | { 52 | // Initialize the service 53 | services.GetRequiredService(type); 54 | } 55 | } 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Utilities/StatsUtility.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using DSharpPlus.Entities; 6 | using MomentumDiscordBot.Constants; 7 | using MomentumDiscordBot.Models; 8 | using MomentumDiscordBot.Models.Data; 9 | 10 | namespace MomentumDiscordBot.Utilities 11 | { 12 | public static class StatsUtility 13 | { 14 | public static async Task<List<(T Grouping, long MessageCount)>> GetTopMessages<T>(Configuration config, 15 | Func<DailyMessageCount, T> groupFunc) 16 | { 17 | await using var dbContext = DbContextHelper.GetNewDbContext(config); 18 | 19 | return dbContext.DailyMessageCount.ToList().GroupBy(groupFunc) 20 | .Select(x => (Grouping: x.Key, MessageCount: x.ToList().Sum(x => x.MessageCount))) 21 | .OrderByDescending(x => x.MessageCount) 22 | .Take(10) 23 | .ToList(); 24 | } 25 | 26 | public static DiscordEmbedBuilder GetTopStatsEmbedBuilder<T>( 27 | this List<(T Grouping, long MessageCount)> topStats, 28 | string title, 29 | Func<(T Grouping, long MessageCount), string> elementStringConverterFunc) => 30 | new() 31 | { 32 | Title = title, 33 | Description = string.Join(Environment.NewLine, 34 | topStats.Select(elementStringConverterFunc)), 35 | Color = MomentumColor.Blue 36 | }; 37 | 38 | public static async Task<List<DailyMessageCount>> GetMessages(Configuration config, 39 | Func<DailyMessageCount, bool> whereFunc) 40 | { 41 | await using var dbContext = DbContextHelper.GetNewDbContext(config); 42 | 43 | return dbContext.DailyMessageCount 44 | .ToList() 45 | .Where(whereFunc) 46 | .ToList(); 47 | } 48 | 49 | public static async Task<long> GetTotalMessageCount(Configuration config) 50 | { 51 | await using var dbContext = DbContextHelper.GetNewDbContext(config); 52 | 53 | return dbContext.DailyMessageCount.Sum(x => x.MessageCount); 54 | } 55 | } 56 | } -------------------------------------------------------------------------------- /MomentumDiscordBot/Utilities/StringExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Linq; 2 | 3 | namespace MomentumDiscordBot.Utilities 4 | { 5 | public static class StringExtensions 6 | { 7 | public static int GetNthIndex(this string input, char searchInput, int nOccurrence) 8 | { 9 | var count = 0; 10 | for (var i = 0; i < input.Length; i++) 11 | { 12 | if (input[i] != searchInput) 13 | { 14 | continue; 15 | } 16 | 17 | count++; 18 | if (count == nOccurrence) 19 | { 20 | return i; 21 | } 22 | } 23 | 24 | return -1; 25 | } 26 | 27 | public static string RemoveControlChars(this string input) 28 | => new(input.Where(c => !char.IsControl(c) && c != '\u1652' && c != 'ٴ').ToArray()); 29 | } 30 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # This repo is no longer being updated 2 | 3 | This project has been rewritten in TypeScript and moved to our [website](https://github.com/momentum-mod/website) monorepo, 4 | living in the [discord-bot-internal package](https://github.com/momentum-mod/website/tree/main/apps/discord-bot-internal). 5 | 6 | # Momentum Mod Discord Bot 7 | 8 | ![Momentum Mod](https://momentum-mod.org/assets/images/logo.svg) 9 | 10 | > A Discord bot for Momentum Mod's Official Discord, running in a [dockerized](https://www.docker.com/) [.NET Core](https://docs.microsoft.com/en-us/dotnet/core/) container, using [DSharpPlus](https://github.com/DSharpPlus/DSharpPlus). 11 | 12 | ![.NET Core](https://github.com/momentum-mod/discord-bot/workflows/.NET%20Core/badge.svg?branch=net-core) 13 | 14 | ## Purpose 15 | 16 | The bot is used to manage and accompany the Discord server: 17 | 18 | * Monitor Twitch livestreams playing Momentum Mod 19 | * Get custom notification roles 20 | * Force users to read the FAQ 21 | 22 | ## Dependencies 23 | 24 | * [Docker Compose V3.8+](https://docs.docker.com/compose/install/) 25 | * [SQLite 3](https://www.sqlite.org/) 26 | 27 | ## Dev Setup 28 | 29 | Firstly, you will need to make a test Discord server with the various roles and channels used by the bot. 30 | 31 | Then, clone the repo using a CLI. 32 | 33 | 1. Navigate to the root folder: `cd discord-bot` 34 | 2. Copy env.TEMPLATE to .env.dev: `cp env.TEMPLATE .env.dev` 35 | 3. In config/, copy config.template.json.TEMPLATE to config.json: `cd config/ && cp config.json.TEMPLATE config.json` 36 | 4. Initialise the SQLite DB by running `data/setup-db.sh` 37 | 5. Build and run the Docker containers using Docker Compose with `docker-compose up -d`. For testing changes, you'll 38 | need to rebuild with `docker-compose build`. 39 | 40 | ## Contributing 41 | 42 | Contributions are welcome, though we encourage you either tackle existing issues or ask about ideas in 43 | the [Momentum Mod Discord server](https://discord.gg/momentummod) first. 44 | 45 | Whilst we originally planned to include integrations with Momentum Mod's official API for stat tracking, WR 46 | announcements, etc., we've since decided to move that work to 47 | a [separate repository](https://github.com/momentum-mod/discord-bot-public). Therefore we expect to make relatively few 48 | additions in the future, mostly just small housekeeping features. 49 | 50 | -------------------------------------------------------------------------------- /config/config.template.json: -------------------------------------------------------------------------------- 1 | { 2 | "environment": "", 3 | "bot_token": "", 4 | "guild_id": 0, 5 | "twitch_api_client_id": "", 6 | "twitch_api_token": "", 7 | "admin_id": 0, 8 | "livestream_mention_role_id": 0, 9 | "mention_role_emoji": "\u2705", 10 | "faq_role_emoji": "\u2705", 11 | "mention_roles": [], 12 | "moderator_id": 0, 13 | "streamer_channel": 0, 14 | "roles_channel": 0, 15 | "twitch_user_bans": [], 16 | "admin_bot_channel": 0, 17 | "stream_update_interval": 5, 18 | "join_log_channel": 0, 19 | "message_history_channel": 0, 20 | "new_account_emote": ":saxophone:", 21 | "minimum_stream_viewers_announce": 0, 22 | "faq_channel": 0, 23 | "faq_role": 0, 24 | "developer_id": 0, 25 | "alt_account_emoji": ":leg:", 26 | "media_verified_role": 0, 27 | "media_blacklisted_role": 0, 28 | "media_minimum_days": 3, 29 | "media_minimum_messages": 10, 30 | "seq_address": "", 31 | "seq_token": "" 32 | } 33 | -------------------------------------------------------------------------------- /data/init.sql: -------------------------------------------------------------------------------- 1 | CREATE TABLE IF NOT EXISTS `message_count` 2 | ( 3 | `UserId` bigint unsigned NOT NULL, 4 | `ChannelId` bigint unsigned NOT NULL, 5 | `Date` date NOT NULL, 6 | `MessageCount` mediumint unsigned NOT NULL, 7 | PRIMARY KEY (`UserId`, `ChannelId`, `Date`) 8 | ) 9 | -------------------------------------------------------------------------------- /data/setup-db.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | sqlite3 -batch bot_data.db < init.sql 4 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | discord-bot: 5 | build: 6 | context: . 7 | dockerfile: ./MomentumDiscordBot/Dockerfile 8 | -------------------------------------------------------------------------------- /docker-compose.prod.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | discord-bot: 5 | image: ghcr.io/momentum-mod/discord-bot/mmod-discord-bot:net-core 6 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | discord-bot: 5 | container_name: MomentumDiscordBot 6 | restart: always 7 | volumes: 8 | - ./config:/app/config 9 | - ./data:/app/data -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 | <?xml version="1.0" encoding="utf-8"?> 2 | <configuration> 3 | <packageSources> 4 | <add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" /> 5 | </packageSources> 6 | </configuration> 7 | --------------------------------------------------------------------------------