├── renovate.json ├── .github ├── FUNDING.yml ├── workflows │ ├── build.yml │ └── publish.yml ├── changelog-ci-config.yml └── ISSUE_TEMPLATE │ ├── bug-report.md │ └── feature-request.md ├── Felicity ├── Options │ ├── TwitchOptions.cs │ ├── BungieApiOptions.cs │ └── DiscordBotOptions.cs ├── .config │ └── dotnet-tools.json ├── Util │ ├── HttpClientInstance.cs │ ├── Enums │ │ ├── EmblemCats.cs │ │ └── Wishes.cs │ ├── EmblemReport.cs │ ├── LogAdapter.cs │ ├── MiscUtils.cs │ ├── Embeds.cs │ ├── ResetUtils.cs │ ├── ProfileHelper.cs │ ├── BotVariables.cs │ ├── Preconditions.cs │ ├── BungieAPIUtils.cs │ ├── WeaponHelper.cs │ ├── EmoteHelper.cs │ └── AutoCompletes.cs ├── Services │ ├── Hosted │ │ ├── Interfaces │ │ │ └── ICommandsInfoService.cs │ │ ├── TwitchStartupService.cs │ │ ├── BungieClientStartupService.cs │ │ ├── StatusService.cs │ │ ├── ResetService.cs │ │ └── CommandsInfoService.cs │ └── BungieAuthCacheService.cs ├── DiscordCommands │ ├── Interactions │ │ ├── StaffCommands.cs │ │ ├── SupportCommands.cs │ │ ├── UserCommands.cs │ │ ├── MetricsCommands.cs │ │ ├── PbCommands.cs │ │ ├── CheckpointCommands.cs │ │ ├── LootCommands.cs │ │ ├── MementoCommands.cs │ │ ├── RollFinderCommands.cs │ │ ├── NewsCommands.cs │ │ ├── ServerCommands.cs │ │ └── VendorCommands.cs │ └── Text │ │ └── EmblemTextCommands.cs ├── Models │ ├── Caches │ │ ├── EmoteCache.cs │ │ ├── MementoCache.cs │ │ ├── GunsmithCache.cs │ │ └── ModCache.cs │ ├── CommandsInfo │ │ ├── CommandParameterInfo.cs │ │ └── CommandInfo.cs │ ├── Metric.cs │ ├── Server.cs │ ├── TwitchStream.cs │ ├── Checkpoints.cs │ ├── User.cs │ ├── RecommendedRolls.cs │ └── Clarity.cs ├── appsettings.json ├── Properties │ ├── launchSettings.json │ └── PublishProfiles │ │ └── FolderProfile.pubxml ├── Extensions │ └── ServiceCollectionExtensions.cs ├── Felicity.csproj ├── Controllers │ └── BungieAuthController.cs └── Program.cs ├── package.json ├── .dockerignore ├── Dockerfile ├── CONTRIBUTING.md ├── Felicity.sln ├── .all-contributorsrc ├── .gitignore ├── CODE_OF_CONDUCT.md └── README.md /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | ko_fi: mooniegz 4 | github: mooniegz 5 | custom: "https://pay.moons.bio" 6 | -------------------------------------------------------------------------------- /Felicity/Options/TwitchOptions.cs: -------------------------------------------------------------------------------- 1 | namespace Felicity.Options; 2 | 3 | public class TwitchOptions 4 | { 5 | public string? AccessToken { get; set; } 6 | public string? ClientId { get; set; } 7 | } 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "devDependencies": { 3 | "cz-conventional-changelog": "^3.3.0" 4 | }, 5 | "config": { 6 | "commitizen": { 7 | "path": "./node_modules/cz-conventional-changelog" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Felicity/.config/dotnet-tools.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": 1, 3 | "isRoot": true, 4 | "tools": { 5 | "dotnet-ef": { 6 | "version": "7.0.10", 7 | "commands": [ 8 | "dotnet-ef" 9 | ] 10 | } 11 | } 12 | } -------------------------------------------------------------------------------- /Felicity/Util/HttpClientInstance.cs: -------------------------------------------------------------------------------- 1 | namespace Felicity.Util; 2 | 3 | public static class HttpClientInstance 4 | { 5 | private static readonly HttpClient? _httpClient; 6 | 7 | public static HttpClient Instance => _httpClient ?? new HttpClient(); 8 | } 9 | -------------------------------------------------------------------------------- /Felicity/Services/Hosted/Interfaces/ICommandsInfoService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using Felicity.Models.CommandsInfo; 3 | 4 | namespace Felicity.Services.Hosted.Interfaces; 5 | 6 | public interface ICommandsInfoService 7 | { 8 | ReadOnlyCollection CommandsInfo { get; } 9 | void Initialize(); 10 | } 11 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/StaffCommands.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | using Felicity.Util; 3 | 4 | // ReSharper disable UnusedType.Global 5 | // ReSharper disable UnusedMember.Global 6 | 7 | namespace Felicity.DiscordCommands.Interactions; 8 | 9 | [Preconditions.RequireBotModerator] 10 | public class StaffCommands : InteractionModuleBase 11 | { 12 | } 13 | -------------------------------------------------------------------------------- /Felicity/Options/BungieApiOptions.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable UnusedAutoPropertyAccessor.Global 2 | 3 | namespace Felicity.Options; 4 | 5 | public class BungieApiOptions 6 | { 7 | public string? ApiKey { get; set; } 8 | public int ClientId { get; set; } 9 | public string? ClientSecret { get; set; } 10 | public string? ManifestPath { get; set; } 11 | public string? EmblemReportApiKey { get; set; } 12 | } 13 | -------------------------------------------------------------------------------- /.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 -------------------------------------------------------------------------------- /Felicity/Options/DiscordBotOptions.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | 3 | namespace Felicity.Options; 4 | 5 | public class DiscordBotOptions 6 | { 7 | public string? Token { get; set; } 8 | public string? Prefix { get; set; } 9 | public ulong LogChannelId { get; set; } 10 | public ulong LogServerId { get; set; } 11 | public ulong[]? BotStaff { get; set; } 12 | public ulong[]? BannedUsers { get; set; } 13 | 14 | public Func LogFormat { get; set; } = 15 | (message, _) => $"{message.Source}: {message.Message}"; 16 | } 17 | -------------------------------------------------------------------------------- /Felicity/Models/Caches/EmoteCache.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable UnusedAutoPropertyAccessor.Global 2 | // ReSharper disable PropertyCanBeMadeInitOnly.Global 3 | 4 | namespace Felicity.Models.Caches; 5 | 6 | public class EmoteCache 7 | { 8 | public EmoteSettings Settings { get; set; } = new(); 9 | } 10 | 11 | public class EmoteSettings 12 | { 13 | public ulong[]? ServerIDs { get; set; } 14 | public Dictionary? Emotes { get; set; } 15 | } 16 | 17 | public class Emote 18 | { 19 | public string? Name { get; set; } 20 | 21 | public ulong Id { get; set; } 22 | } 23 | -------------------------------------------------------------------------------- /Felicity/Models/CommandsInfo/CommandParameterInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | // ReSharper disable UnusedAutoPropertyAccessor.Global 4 | // ReSharper disable UnusedMember.Global 5 | #pragma warning disable CS8618 6 | 7 | namespace Felicity.Models.CommandsInfo; 8 | 9 | [DebuggerDisplay("{Name}")] 10 | public class CommandParameterInfo 11 | { 12 | public string Name { get; set; } 13 | public string Description { get; set; } 14 | public bool IsOptional { get; set; } 15 | public object DefaultValue { get; set; } 16 | public bool IsAutocomplete { get; set; } 17 | public bool IsSelect { get; set; } 18 | } 19 | -------------------------------------------------------------------------------- /Felicity/Models/CommandsInfo/CommandInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | 3 | // ReSharper disable MemberCanBePrivate.Global 4 | // ReSharper disable UnusedAutoPropertyAccessor.Global 5 | 6 | namespace Felicity.Models.CommandsInfo; 7 | 8 | [DebuggerDisplay("{CommandPath}, Parameters count = {ParametersInfo.Count}")] 9 | public class CommandInfo 10 | { 11 | public CommandInfo(string path, string desc) 12 | { 13 | CommandPath = path; 14 | CommandDescription = desc; 15 | ParametersInfo = new List(); 16 | } 17 | 18 | public string CommandPath { get; } 19 | public string CommandDescription { get; } 20 | public List ParametersInfo { get; } 21 | } 22 | -------------------------------------------------------------------------------- /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/aspnet:8.0 AS base 4 | WORKDIR /app 5 | EXPOSE 80 6 | EXPOSE 443 7 | 8 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 9 | WORKDIR /src 10 | COPY ["Felicity/Felicity.csproj", "Felicity/"] 11 | RUN dotnet restore "Felicity/Felicity.csproj" 12 | COPY . . 13 | WORKDIR "/src/Felicity" 14 | RUN dotnet build "Felicity.csproj" -c Release -o /app/build 15 | 16 | FROM build AS publish 17 | RUN dotnet publish "Felicity.csproj" -c Release -o /app/publish 18 | 19 | FROM base AS final 20 | WORKDIR /app 21 | COPY --from=publish /app/publish . 22 | ENTRYPOINT ["dotnet", "Felicity.dll"] 23 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build Check 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - name: Checkout repository 14 | uses: actions/checkout@v3.6.0 15 | 16 | - name: Setup .NET 17 | uses: actions/setup-dotnet@v3 18 | with: 19 | dotnet-version: '8.0.x' 20 | 21 | - name: Restore dependencies 22 | run: dotnet restore Felicity/Felicity.csproj 23 | 24 | - name: Build application 25 | run: dotnet build Felicity/Felicity.csproj --configuration Release --no-restore 26 | 27 | - name: Check build status 28 | run: dotnet build Felicity/Felicity.csproj --configuration Release --no-restore --no-incremental 29 | -------------------------------------------------------------------------------- /Felicity/Util/Enums/EmblemCats.cs: -------------------------------------------------------------------------------- 1 | namespace Felicity.Util.Enums; 2 | 3 | internal enum EmblemCat : uint 4 | { 5 | Seasonal = 2451657441, 6 | Account = 24961706, 7 | General = 1166184619, 8 | Competitive = 1801524334, 9 | Gambit = 4111024827, 10 | Strikes = 3958514834, 11 | World = 631010939, 12 | Trials = 2220993106, 13 | Raids = 329982304 14 | } 15 | 16 | internal static class EmblemCats 17 | { 18 | public static readonly List EmblemCatList = new() 19 | { 20 | EmblemCat.Seasonal, 21 | EmblemCat.Account, 22 | EmblemCat.General, 23 | EmblemCat.Competitive, 24 | EmblemCat.Gambit, 25 | EmblemCat.Strikes, 26 | EmblemCat.World, 27 | EmblemCat.Trials, 28 | EmblemCat.Raids 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /.github/changelog-ci-config.yml: -------------------------------------------------------------------------------- 1 | changelog_type: 'commit_message' # or 'pull_request' 2 | header_prefix: 'Version:' 3 | commit_changelog: true 4 | comment_changelog: true 5 | include_unlabeled_changes: true 6 | unlabeled_group_title: 'Unlabeled Changes' 7 | pull_request_title_regex: '^Release' 8 | version_regex: 'v?([0-9]{1,2})+[.]+([0-9]{1,2})+[.]+([0-9]{1,2})\s\(\d{1,2}-\d{1,2}-\d{4}\)' 9 | exclude_labels: 10 | - bot 11 | - dependabot 12 | - dependencies 13 | - ci 14 | group_config: 15 | - title: Bug Fixes 16 | labels: 17 | - bug 18 | - bugfix 19 | - title: Code Improvements 20 | labels: 21 | - improvements 22 | - enhancement 23 | - title: New Features 24 | labels: 25 | - feature 26 | - title: Documentation Updates 27 | labels: 28 | - docs 29 | - documentation 30 | - doc -------------------------------------------------------------------------------- /Felicity/Services/Hosted/TwitchStartupService.cs: -------------------------------------------------------------------------------- 1 | namespace Felicity.Services.Hosted; 2 | 3 | public class TwitchStartupService : BackgroundService 4 | { 5 | private readonly ILogger _logger; 6 | private readonly TwitchService _twitchService; 7 | 8 | public TwitchStartupService( 9 | TwitchService twitchService, 10 | ILogger logger) 11 | { 12 | _twitchService = twitchService; 13 | _logger = logger; 14 | } 15 | 16 | protected override Task ExecuteAsync(CancellationToken stoppingToken) 17 | { 18 | try 19 | { 20 | _twitchService.ConfigureMonitor(); 21 | } 22 | catch (Exception e) 23 | { 24 | _logger.LogError(e, "Exception in TwitchStartupService"); 25 | } 26 | 27 | return Task.CompletedTask; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Felicity/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Debug", 5 | "System": "Information", 6 | "Microsoft": "Information" 7 | } 8 | }, 9 | "DiscordBot": { 10 | "Token": "put_your_token_here", 11 | "Prefix": "f!", 12 | "LogChannelId": 0, 13 | "LogServerId": 0, 14 | "BotStaff": [684854397871849482], 15 | "BannedUsers": [] 16 | }, 17 | "Bungie": { 18 | "ApiKey": "api_key", 19 | "ClientId": 1234, 20 | "ClientSecret": "client_secret", 21 | "ManifestPath": "your_local_manifest_path", 22 | "EmblemReportApiKey": "your_api_key" 23 | }, 24 | "SentryDsn": "sentry_dsn", 25 | "ConnectionStrings": { 26 | "MySQLDb": "connection_string_here" 27 | }, 28 | "Twitch": { 29 | "AccessToken": "", 30 | "ClientId": "" 31 | }, 32 | "Kestrel": { 33 | "Endpoints": { 34 | "Http": { 35 | "Url": "http://*:8082" 36 | } 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /Felicity/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Felicity": { 4 | "commandName": "Project", 5 | "workingDirectory": "bin/Debug/net8.0", 6 | "environmentVariables": { 7 | "ASPNETCORE_ENVIRONMENT": "Development" 8 | }, 9 | "applicationUrl": "https://localhost:4259;http://localhost:4260" 10 | }, 11 | "Docker": { 12 | "commandName": "Docker", 13 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", 14 | "publishAllPorts": true, 15 | "useSSL": true 16 | }, 17 | "WSL": { 18 | "commandName": "WSL2", 19 | "workingDirectory": "{ProjectDir}/bin/Debug/net8.0", 20 | "launchUrl": "https://localhost:4259", 21 | "environmentVariables": { 22 | "ASPNETCORE_ENVIRONMENT": "Development", 23 | "ASPNETCORE_URLS": "https://localhost:4259;http://localhost:4260" 24 | }, 25 | "distributionName": "" 26 | } 27 | } 28 | } -------------------------------------------------------------------------------- /Felicity/Properties/PublishProfiles/FolderProfile.pubxml: -------------------------------------------------------------------------------- 1 | 2 | 5 | 6 | 7 | true 8 | false 9 | true 10 | Release 11 | Any CPU 12 | FileSystem 13 | bin\Release\net6.0\publish\ 14 | FileSystem 15 | 16 | net6.0 17 | win-x64 18 | d150851d-8f3f-40cd-bb34-904ed3cd0935 19 | false 20 | 21 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for taking an interest! Felicity started as a small personal project, but has quickly grown. 4 | 5 | If you'd like to contribute, here are some things you could do: 6 | 7 | - Add the bot to your server and test it. 8 | - Design some artwork and submit it through Discord 9 | - Help with [hosting costs](https://ko-fi.com/axsLeaf) 10 | - Help with [programming](https://github.com/axsLeaf/FelicityOne/tree/main/FelicityOne) 11 | - Make a [feature request](https://github.com/axsLeaf/FelicityOne/issues/new/choose) 12 | - Report a [bug](https://github.com/axsLeaf/FelicityOne/issues/new/choose) 13 | 14 | All contributors will be added to the [Contributors list](https://github.com/axsLeaf/FelicityOne#contributors). If you would like us to omit your contribution from the list, just let us know. 15 | 16 | When contributing, keep the following in mind: 17 | 18 | - Owners and managers can change any contributed material as they see fit. -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Build and Publish Docker Image 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | env: 9 | REGISTRY: ghcr.io 10 | IMAGE_NAME: devfelicity/felicity 11 | 12 | jobs: 13 | build_and_push: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - name: Check Out Code 17 | uses: actions/checkout@v3 18 | 19 | - name: Set Up Docker Buildx 20 | uses: docker/setup-buildx-action@v2 21 | 22 | - name: Login to GitHub Container Registry 23 | uses: docker/login-action@v2 24 | with: 25 | registry: ${{ env.REGISTRY }} 26 | username: ${{ github.actor }} 27 | password: ${{ secrets.GITHUB_TOKEN }} 28 | 29 | - name: Build and Push Docker Image 30 | uses: docker/build-push-action@v4 31 | with: 32 | context: . 33 | push: true 34 | platforms: linux/amd64 35 | tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest 36 | -------------------------------------------------------------------------------- /Felicity/Services/Hosted/BungieClientStartupService.cs: -------------------------------------------------------------------------------- 1 | using DotNetBungieAPI.Service.Abstractions; 2 | 3 | namespace Felicity.Services.Hosted; 4 | 5 | public class BungieClientStartupService : BackgroundService 6 | { 7 | private readonly IBungieClient _bungieClient; 8 | private readonly ILogger _logger; 9 | 10 | public BungieClientStartupService( 11 | IBungieClient bungieClient, 12 | ILogger logger) 13 | { 14 | _bungieClient = bungieClient; 15 | _logger = logger; 16 | } 17 | 18 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 19 | { 20 | try 21 | { 22 | await _bungieClient.DefinitionProvider.Initialize(); 23 | await _bungieClient.DefinitionProvider.ReadToRepository(_bungieClient.Repository); 24 | } 25 | catch (Exception e) 26 | { 27 | _logger.LogError(e, "Exception in BungieClientStartupService"); 28 | } 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Felicity/Util/EmblemReport.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using System.Text.Json.Serialization; 3 | 4 | // ReSharper disable UnassignedGetOnlyAutoProperty 5 | // ReSharper disable ClassNeverInstantiated.Global 6 | // ReSharper disable UnusedAutoPropertyAccessor.Global 7 | 8 | namespace Felicity.Util; 9 | 10 | public class EmblemReport 11 | { 12 | public partial class EmblemResponse 13 | { 14 | [JsonPropertyName("data")] 15 | public List Data { get; set; } = null!; 16 | } 17 | 18 | public class Datum 19 | { 20 | [JsonPropertyName("collectible_hash")] 21 | public uint CollectibleHash { get; set; } 22 | 23 | [JsonPropertyName("acquisition")] 24 | public long Acquisition { get; set; } 25 | 26 | [JsonPropertyName("percentage")] 27 | public double Percentage { get; set; } 28 | } 29 | 30 | public partial class EmblemResponse 31 | { 32 | public static EmblemResponse? FromJson(string json) 33 | { 34 | return JsonSerializer.Deserialize(json); 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /Felicity/Models/Metric.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace Felicity.Models; 5 | 6 | public class Metric 7 | { 8 | [Key] 9 | public int Id { get; set; } 10 | 11 | public int TimeStamp { get; set; } 12 | public string Author { get; set; } = string.Empty; 13 | public string Name { get; set; } = string.Empty; 14 | } 15 | 16 | public class MetricDb : DbContext 17 | { 18 | private readonly string? _connectionString; 19 | 20 | public MetricDb(IConfiguration configuration) 21 | { 22 | _connectionString = configuration.GetConnectionString("MySQLDb"); 23 | } 24 | 25 | // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global 26 | public DbSet Metrics { get; set; } = null!; 27 | 28 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 29 | { 30 | var serverVersion = new MariaDbServerVersion(new Version(10, 2, 21)); 31 | if (_connectionString != null) 32 | optionsBuilder.UseMySql(_connectionString, serverVersion, builder => builder.EnableRetryOnFailure()); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug-report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Tell us about a bug. 4 | title: '' 5 | labels: bug 6 | assignees: axsLeaf 7 | 8 | --- 9 | 10 | 11 | 12 | 16 | 17 | **Bug Description**: 18 | 19 | 20 | 21 | 25 | 26 | **Steps To Reproduce**: 27 | 28 | 29 | 30 | 34 | 35 | **Expected Behaviour**: 36 | 37 | 38 | 39 | 43 | 44 | **Additional Information**: 45 | 46 | 47 | 48 | 54 | 55 | - [ ] I **decline** to being added to the Contributors list. 56 | 57 | -------------------------------------------------------------------------------- /Felicity/Util/LogAdapter.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Felicity.Options; 3 | using Microsoft.Extensions.Options; 4 | 5 | namespace Felicity.Util; 6 | 7 | public class LogAdapter where T : class 8 | { 9 | private readonly Func _formatter; 10 | private readonly ILogger _logger; 11 | 12 | public LogAdapter(ILogger logger, IOptions options) 13 | { 14 | _logger = logger; 15 | _formatter = options.Value.LogFormat; 16 | } 17 | 18 | public Task Log(LogMessage message) 19 | { 20 | _logger.Log(GetLogLevel(message.Severity), default, message, message.Exception, _formatter); 21 | return Task.CompletedTask; 22 | } 23 | 24 | private static LogLevel GetLogLevel(LogSeverity severity) 25 | { 26 | return severity switch 27 | { 28 | LogSeverity.Critical => LogLevel.Critical, 29 | LogSeverity.Error => LogLevel.Error, 30 | LogSeverity.Warning => LogLevel.Warning, 31 | LogSeverity.Info => LogLevel.Information, 32 | LogSeverity.Verbose => LogLevel.Debug, 33 | LogSeverity.Debug => LogLevel.Trace, 34 | _ => throw new ArgumentOutOfRangeException(nameof(severity), severity, null) 35 | }; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Suggest a feature or command. 4 | title: '' 5 | labels: enhancement 6 | assignees: axsLeaf 7 | 8 | --- 9 | 10 | 11 | 12 | 16 | 17 | **Feature Description**: 18 | 19 | 23 | 24 | **Alternatives**: 25 | 26 | 27 | 28 | 32 | 33 | **Reasoning**: 34 | 35 | 36 | 37 | 41 | 42 | **Additional Information**: 43 | 44 | 45 | 46 | 52 | 53 | - [ ] I **decline** to being added to the Contributors list. 54 | 55 | -------------------------------------------------------------------------------- /Felicity/Services/BungieAuthCacheService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Concurrent; 2 | using System.Text.Json; 3 | using DotNetBungieAPI.Models.Authorization; 4 | using Microsoft.AspNetCore.Authentication.OAuth; 5 | 6 | namespace Felicity.Services; 7 | 8 | public static class BungieAuthCacheService 9 | { 10 | private static ConcurrentDictionary 11 | // ReSharper disable once FieldCanBeMadeReadOnly.Local 12 | #pragma warning disable IDE0044 // Add readonly modifier 13 | _authContexts = new(); 14 | #pragma warning restore IDE0044 // Add readonly modifier 15 | 16 | private static JsonSerializerOptions? _jsonSerializerOptions; 17 | 18 | public static void Initialize(JsonSerializerOptions jsonSerializerOptions) 19 | { 20 | _jsonSerializerOptions = jsonSerializerOptions; 21 | } 22 | 23 | public static void TryAddContext(OAuthCreatingTicketContext authCreatingTicketContext) 24 | { 25 | var tokenData = 26 | authCreatingTicketContext.TokenResponse.Response!.Deserialize( 27 | _jsonSerializerOptions); 28 | _authContexts.TryAdd(tokenData!.MembershipId, (authCreatingTicketContext, tokenData)); 29 | } 30 | 31 | public static bool GetByIdAndRemove(long id, 32 | out (OAuthCreatingTicketContext Context, AuthorizationTokenData Token) context) 33 | { 34 | return _authContexts.TryRemove(id, out context); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Felicity/Models/Caches/MementoCache.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | // ReSharper disable UnusedAutoPropertyAccessor.Global 4 | 5 | namespace Felicity.Models.Caches; 6 | 7 | public class MementoCache 8 | { 9 | public MementoInventoryElement[]? MementoInventory { get; set; } 10 | 11 | public class MementoInventoryElement 12 | { 13 | public MementoSource Source { get; set; } 14 | public MementoWeaponList[]? WeaponList { get; set; } 15 | } 16 | 17 | public class MementoWeaponList 18 | { 19 | public string? WeaponName { get; set; } 20 | public MementoTypeList[]? TypeList { get; set; } 21 | } 22 | 23 | public class MementoTypeList 24 | { 25 | public MementoType Type { get; set; } 26 | public Memento? Memento { get; set; } 27 | } 28 | 29 | public class Memento 30 | { 31 | public string? Credit { get; set; } 32 | public string? ImageUrl { get; set; } 33 | } 34 | } 35 | 36 | public static class ProcessMementoData 37 | { 38 | private const string FilePath = "Data/mementoCache.json"; 39 | 40 | public static async Task ReadJsonAsync() 41 | { 42 | await using var stream = File.OpenRead(FilePath); 43 | return await JsonSerializer.DeserializeAsync(stream); 44 | } 45 | } 46 | 47 | public enum MementoType 48 | { 49 | Gambit, 50 | Nightfall, 51 | Trials 52 | } 53 | 54 | public enum MementoSource 55 | { 56 | OpenWorld, 57 | RaidVotD, 58 | SeasonRisen, 59 | SeasonHaunted, 60 | ThroneWorld 61 | } 62 | -------------------------------------------------------------------------------- /Felicity.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.2.32616.157 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Felicity", "Felicity\Felicity.csproj", "{D150851D-8F3F-40CD-BB34-904ED3CD0935}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E25BFF59-2E80-4217-821B-18F330B54B4B}" 9 | ProjectSection(SolutionItems) = preProject 10 | CHANGELOG.md = CHANGELOG.md 11 | CODE_OF_CONDUCT.md = CODE_OF_CONDUCT.md 12 | CONTRIBUTING.md = CONTRIBUTING.md 13 | Dockerfile = Dockerfile 14 | LICENSE.md = LICENSE.md 15 | README.md = README.md 16 | EndProjectSection 17 | EndProject 18 | Global 19 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 20 | Debug|Any CPU = Debug|Any CPU 21 | Release|Any CPU = Release|Any CPU 22 | EndGlobalSection 23 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 24 | {D150851D-8F3F-40CD-BB34-904ED3CD0935}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {D150851D-8F3F-40CD-BB34-904ED3CD0935}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {D150851D-8F3F-40CD-BB34-904ED3CD0935}.Release|Any CPU.ActiveCfg = Release|Any CPU 27 | {D150851D-8F3F-40CD-BB34-904ED3CD0935}.Release|Any CPU.Build.0 = Release|Any CPU 28 | EndGlobalSection 29 | GlobalSection(SolutionProperties) = preSolution 30 | HideSolutionNode = FALSE 31 | EndGlobalSection 32 | GlobalSection(ExtensibilityGlobals) = postSolution 33 | SolutionGuid = {44EE387E-EF7E-45D8-9658-3B88CCDBF797} 34 | EndGlobalSection 35 | EndGlobal 36 | -------------------------------------------------------------------------------- /Felicity/Models/Server.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using DotNetBungieAPI.Models; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | // ReSharper disable UnusedMember.Global 6 | // ReSharper disable PropertyCanBeMadeInitOnly.Global 7 | // ReSharper disable UnusedAutoPropertyAccessor.Global 8 | 9 | namespace Felicity.Models; 10 | 11 | public class Server 12 | { 13 | [Key] 14 | public ulong ServerId { get; set; } 15 | 16 | public BungieLocales BungieLocale { get; set; } 17 | public ulong? AnnouncementChannel { get; set; } 18 | public ulong? StaffChannel { get; set; } 19 | public ulong? D2Daily { get; set; } 20 | public ulong? D2Weekly { get; set; } 21 | public ulong? D2Ada { get; set; } 22 | public ulong? D2Gunsmith { get; set; } 23 | public ulong? D2Xur { get; set; } 24 | public ulong? MemberLogChannel { get; set; } 25 | public bool? MemberJoined { get; set; } 26 | public bool? MemberLeft { get; set; } 27 | } 28 | 29 | public class ServerDb : DbContext 30 | { 31 | private readonly string? _connectionString; 32 | 33 | public ServerDb(IConfiguration configuration) 34 | { 35 | _connectionString = configuration.GetConnectionString("MySQLDb"); 36 | } 37 | 38 | // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global 39 | public DbSet Servers { get; set; } = null!; 40 | 41 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 42 | { 43 | var serverVersion = new MariaDbServerVersion(new Version(10, 2, 21)); 44 | if (_connectionString != null) 45 | optionsBuilder.UseMySql(_connectionString, serverVersion, builder => builder.EnableRetryOnFailure()); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Felicity/Util/MiscUtils.cs: -------------------------------------------------------------------------------- 1 | using Discord.WebSocket; 2 | using DotNetBungieAPI.Models; 3 | using Felicity.Models; 4 | 5 | namespace Felicity.Util; 6 | 7 | public static class MiscUtils 8 | { 9 | public static double GetTimestamp(this DateTime dateTime) 10 | { 11 | return dateTime.Subtract(new DateTime(1970, 1, 1)).TotalSeconds; 12 | } 13 | 14 | public static DateTime TimeStampToDateTime(double unixTimeStamp) 15 | { 16 | var dateTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); 17 | dateTime = dateTime.AddSeconds(unixTimeStamp).ToLocalTime(); 18 | return dateTime; 19 | } 20 | 21 | public static Server GetServer(ServerDb serverDb, ulong guildId) 22 | { 23 | var server = serverDb.Servers.FirstOrDefault(x => x.ServerId == guildId); 24 | if (server != null) 25 | return server; 26 | 27 | server = new Server 28 | { 29 | ServerId = guildId, 30 | BungieLocale = BungieLocales.EN 31 | }; 32 | serverDb.Servers.Add(server); 33 | 34 | return server; 35 | } 36 | 37 | public static string GetLightGgLink(uint itemId) 38 | { 39 | var shareId = Convert.ToBase64String(BitConverter.GetBytes(itemId)).Replace('+', '-').Replace('/', '_') 40 | .Trim('='); 41 | return $"https://light.gg/i/{shareId}"; 42 | } 43 | 44 | public static BungieLocales GetLanguage(SocketGuild? contextGuild, ServerDb serverDb) 45 | { 46 | if (contextGuild == null) 47 | return BungieLocales.EN; 48 | 49 | return serverDb.Servers.FirstOrDefault(x => x.ServerId == contextGuild.Id)?.BungieLocale ?? 50 | BungieLocales.EN; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Felicity/Extensions/ServiceCollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | using Discord.Interactions; 3 | using Discord.WebSocket; 4 | using Felicity.Options; 5 | using Felicity.Services.Hosted; 6 | using Fergun.Interactive; 7 | 8 | namespace Felicity.Extensions; 9 | 10 | public static class ServiceCollectionExtensions 11 | { 12 | public static IServiceCollection AddDiscord( 13 | this IServiceCollection serviceCollection, 14 | Action configureClient, 15 | Action configureInteractionService, 16 | Action configureTextCommands, 17 | IConfiguration configuration) 18 | { 19 | var discordSocketConfig = new DiscordSocketConfig(); 20 | configureClient(discordSocketConfig); 21 | var discordClient = new DiscordShardedClient(discordSocketConfig); 22 | 23 | var interactionServiceConfig = new InteractionServiceConfig(); 24 | configureInteractionService(interactionServiceConfig); 25 | var interactionService = new InteractionService(discordClient, interactionServiceConfig); 26 | 27 | var commandServiceConfig = new CommandServiceConfig(); 28 | configureTextCommands(commandServiceConfig); 29 | var textCommandService = new CommandService(commandServiceConfig); 30 | 31 | return serviceCollection 32 | .Configure(configuration.GetSection("DiscordBot")) 33 | .AddHostedService() 34 | .AddSingleton(discordClient) 35 | .AddSingleton(interactionService) 36 | .AddSingleton(new InteractiveConfig { DefaultTimeout = TimeSpan.FromMinutes(2) }) 37 | .AddSingleton() 38 | .AddSingleton(textCommandService); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/SupportCommands.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.Interactions; 3 | using Felicity.Util; 4 | 5 | // ReSharper disable UnusedType.Global 6 | // ReSharper disable UnusedMember.Global 7 | 8 | namespace Felicity.DiscordCommands.Interactions; 9 | 10 | public class SupportCommands : InteractionModuleBase 11 | { 12 | [SlashCommand("support", "Get helpful links for the Felicity project.")] 13 | public async Task Support() 14 | { 15 | await DeferAsync(); 16 | 17 | var embed = Embeds.MakeBuilder(); 18 | embed.Title = "Thank you for your interest in Felicity."; 19 | embed.Color = Color.Green; 20 | embed.ThumbnailUrl = BotVariables.Images.FelicitySquare; 21 | embed.Description = Format.Bold("--- Useful links:") + 22 | $"\n<:discord:994211332301791283> [Support Server]({BotVariables.DiscordInvite})" + 23 | "\n<:twitter:994216171110932510> [Twitter](https://twitter.com/devFelicity)" + 24 | "\n🌐 [Website](https://tryfelicity.one)" + 25 | "\n\n" + Format.Bold("--- Contribute to upkeep:") + 26 | "\n• <:kofi:994212063041835098> Donate one-time or monthly on [Ko-Fi](https://ko-fi.com/mooniegz)" + 27 | "\n• <:paypal:994215375141097493> Donate any amount through [PayPal](https://donate.tryfelicity.one)" + 28 | "\n• <:github:994212386204549160> Become a sponsor on [GitHub](https://github.com/sponsors/MoonieGZ)" + 29 | "\n• <:twitch:994214014055895040> Subscribe on [Twitch](https://twitch.tv/subs/MoonieGZ) *(free once per month with Amazon Prime)*"; 30 | 31 | await FollowupAsync(embed: embed.Build()); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Felicity/Models/TwitchStream.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | using Microsoft.EntityFrameworkCore; 4 | 5 | // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable PropertyCanBeMadeInitOnly.Global 8 | // ReSharper disable UnusedAutoPropertyAccessor.Global 9 | 10 | namespace Felicity.Models; 11 | 12 | public class TwitchStream 13 | { 14 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 15 | [Key] 16 | public int Id { get; set; } 17 | 18 | public string TwitchName { get; set; } = string.Empty; 19 | public ulong ServerId { get; set; } 20 | public ulong ChannelId { get; set; } 21 | public ulong? UserId { get; set; } 22 | public ulong? MentionRole { get; set; } 23 | public bool MentionEveryone { get; set; } 24 | } 25 | 26 | public class ActiveStream 27 | { 28 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 29 | [Key] 30 | public int Id { get; set; } 31 | 32 | public int ConfigId { get; set; } 33 | public ulong StreamId { get; set; } 34 | public ulong MessageId { get; set; } 35 | } 36 | 37 | public class TwitchStreamDb : DbContext 38 | { 39 | private readonly string? _connectionString; 40 | 41 | public TwitchStreamDb(IConfiguration configuration) 42 | { 43 | _connectionString = configuration.GetConnectionString("MySQLDb"); 44 | } 45 | 46 | public DbSet TwitchStreams { get; set; } = null!; 47 | public DbSet ActiveStreams { get; set; } = null!; 48 | 49 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 50 | { 51 | var serverVersion = new MariaDbServerVersion(new Version(10, 2, 21)); 52 | if (_connectionString != null) 53 | optionsBuilder.UseMySql(_connectionString, serverVersion, builder => builder.EnableRetryOnFailure()); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Felicity/Services/Hosted/StatusService.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.WebSocket; 3 | using Felicity.Util; 4 | 5 | namespace Felicity.Services.Hosted; 6 | 7 | public class StatusService : BackgroundService 8 | { 9 | private static readonly List GameList = new() 10 | { 11 | new Game("Shutting down May 9th, use Levante.") 12 | }; 13 | 14 | private readonly TimeSpan _delay = TimeSpan.FromMinutes(15); 15 | private readonly DiscordShardedClient _discordClient; 16 | private readonly ILogger _logger; 17 | 18 | public StatusService( 19 | DiscordShardedClient discordClient, 20 | ILogger logger) 21 | { 22 | _discordClient = discordClient; 23 | _logger = logger; 24 | } 25 | 26 | private static Game LastGame { get; set; } = null!; 27 | 28 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 29 | { 30 | try 31 | { 32 | await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); 33 | 34 | while (!stoppingToken.IsCancellationRequested) 35 | { 36 | Game newGame; 37 | do 38 | { 39 | newGame = GameList[Random.Shared.Next(GameList.Count)]; 40 | } while (newGame == LastGame); 41 | 42 | try 43 | { 44 | await _discordClient.SetActivityAsync(newGame); 45 | _logger.LogInformation("Set game to: {Name}", newGame.Name); 46 | LastGame = newGame; 47 | } 48 | catch 49 | { 50 | // ignored 51 | } 52 | 53 | await Task.Delay(_delay, stoppingToken); 54 | } 55 | } 56 | catch (Exception e) 57 | { 58 | _logger.LogError(e, "Exception in StatusService"); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Felicity/Util/Embeds.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.WebSocket; 3 | 4 | // ReSharper disable StringLiteralTypo 5 | 6 | namespace Felicity.Util; 7 | 8 | public static class Embeds 9 | { 10 | public static readonly Color DefaultColor = new(239, 35, 60); 11 | 12 | public static EmbedBuilder MakeBuilder() 13 | { 14 | var builder = new EmbedBuilder 15 | { 16 | Color = DefaultColor, 17 | Footer = MakeFooter() 18 | }; 19 | 20 | return builder; 21 | } 22 | 23 | public static EmbedFooterBuilder MakeFooter() 24 | { 25 | return new EmbedFooterBuilder 26 | { 27 | Text = $"Felicity v.{BotVariables.Version} | tryfelicity.one | Consider using /support :)", 28 | IconUrl = BotVariables.Images.FelicityCircle 29 | }; 30 | } 31 | 32 | public static EmbedBuilder GenerateGuildUser(SocketUser socketUser) 33 | { 34 | var embed = MakeBuilder(); 35 | 36 | embed.Author = new EmbedAuthorBuilder 37 | { 38 | IconUrl = socketUser.GetAvatarUrl(), 39 | Name = socketUser.Username 40 | }; 41 | embed.Fields = new List 42 | { 43 | new() 44 | { 45 | IsInline = true, 46 | Name = "Account Created", 47 | Value = socketUser.CreatedAt.ToString("d") 48 | }, 49 | new() 50 | { 51 | IsInline = true, 52 | Name = "User ID", 53 | Value = socketUser.Id 54 | } 55 | }; 56 | 57 | return embed; 58 | } 59 | 60 | public static EmbedBuilder MakeErrorEmbed() 61 | { 62 | var builder = new EmbedBuilder 63 | { 64 | Color = Color.Red, 65 | Footer = MakeFooter(), 66 | ThumbnailUrl = BotVariables.Images.SadFace 67 | }; 68 | 69 | return builder; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Felicity/Util/ResetUtils.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable UnusedType.Global 2 | // ReSharper disable UnusedMember.Global 3 | // ReSharper disable MemberCanBePrivate.Global 4 | 5 | namespace Felicity.Util; 6 | 7 | public static class ResetUtils 8 | { 9 | private const int DailyResetHour = 17; 10 | 11 | private static int ConvertDayOfWeekToInt(DayOfWeek dayOfWeek) 12 | { 13 | return dayOfWeek switch 14 | { 15 | DayOfWeek.Sunday => 7, 16 | DayOfWeek.Monday => 1, 17 | DayOfWeek.Tuesday => 2, 18 | DayOfWeek.Wednesday => 3, 19 | DayOfWeek.Thursday => 4, 20 | DayOfWeek.Friday => 5, 21 | DayOfWeek.Saturday => 6, 22 | _ => throw new ArgumentOutOfRangeException(nameof(dayOfWeek), dayOfWeek, null) 23 | }; 24 | } 25 | 26 | public static DateTime GetNextDailyReset() 27 | { 28 | var currentDate = DateTime.UtcNow; 29 | var dateInQuestion = new DateTime( 30 | currentDate.Year, 31 | currentDate.Month, 32 | currentDate.Day, 33 | DailyResetHour, 0, 0); 34 | return dateInQuestion < currentDate ? dateInQuestion.AddDays(1) : dateInQuestion; 35 | } 36 | 37 | public static DateTime GetNextWeeklyReset(int day) 38 | { 39 | var currentDate = DateTime.UtcNow; 40 | 41 | var dateInQuestion = new DateTime(currentDate.Year, currentDate.Month, currentDate.Day, DailyResetHour, 0, 0); 42 | 43 | var currentDay = ConvertDayOfWeekToInt(dateInQuestion.DayOfWeek); 44 | 45 | if (currentDay < day) 46 | dateInQuestion = dateInQuestion.AddDays(day - currentDay); 47 | else if (currentDay > day) dateInQuestion = dateInQuestion.AddDays(7 - (currentDay - day)); 48 | 49 | return dateInQuestion; 50 | } 51 | 52 | public static DateTime GetNextWeeklyReset(DayOfWeek day) 53 | { 54 | var dayNumber = ConvertDayOfWeekToInt(day); 55 | return GetNextWeeklyReset(dayNumber); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/UserCommands.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.Interactions; 3 | using Felicity.Models; 4 | using Felicity.Util; 5 | 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedType.Global 8 | 9 | namespace Felicity.DiscordCommands.Interactions; 10 | 11 | [Group("user", "Manage user settings for the bot.")] 12 | public class UserCommands : InteractionModuleBase 13 | { 14 | private readonly UserDb _userDb; 15 | 16 | public UserCommands(UserDb userDb) 17 | { 18 | _userDb = userDb; 19 | } 20 | 21 | [SlashCommand("register", "Register your bungie profile to the bot.")] 22 | public async Task UserRegister() 23 | { 24 | await DeferAsync(true); 25 | 26 | var embed = Embeds.MakeBuilder(); 27 | embed.Description = "Use the link below to register your Bungie profile with Felicity.\n" 28 | + "We securely store authentication keys to access your profile information, collections, records, and more.\n" 29 | + "**If you don't want us to store your data, please refrain from proceeding.**\n\n" 30 | + $"[Click here to register.](https://auth.tryfelicity.one/auth/bungie_net/{Context.User.Id})"; 31 | 32 | await FollowupAsync(embed: embed.Build(), ephemeral: true); 33 | } 34 | 35 | [SlashCommand("remove", "Remove your profile link from the bot.")] 36 | public async Task UserRemove() 37 | { 38 | await DeferAsync(true); 39 | 40 | var embed = Embeds.MakeBuilder(); 41 | 42 | var user = _userDb.Users.FirstOrDefault(x => x.DiscordId == Context.User.Id); 43 | 44 | if (user != null) 45 | { 46 | _userDb.Users.Remove(user); 47 | 48 | embed.Color = Color.Green; 49 | embed.Description = "Your profile has been removed from Felicity."; 50 | 51 | await _userDb.SaveChangesAsync(); 52 | } 53 | else 54 | { 55 | embed.Color = Color.Red; 56 | embed.Description = "You are not currently registered with Felicity."; 57 | } 58 | 59 | await FollowupAsync(embed: embed.Build(), ephemeral: true); 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Felicity/Services/Hosted/ResetService.cs: -------------------------------------------------------------------------------- 1 | using Discord.WebSocket; 2 | using DotNetBungieAPI.Service.Abstractions; 3 | 4 | namespace Felicity.Services.Hosted; 5 | 6 | public class ResetService : BackgroundService 7 | { 8 | private readonly IBungieClient _bungieClient; 9 | 10 | private readonly TimeSpan _delay = TimeSpan.FromMinutes(10); 11 | private readonly DiscordShardedClient _discordClient; 12 | private readonly ILogger _logger; 13 | 14 | public ResetService( 15 | IBungieClient bungieClient, 16 | DiscordShardedClient discordClient, 17 | ILogger logger) 18 | { 19 | _bungieClient = bungieClient; 20 | _discordClient = discordClient; 21 | _logger = logger; 22 | } 23 | 24 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 25 | { 26 | try 27 | { 28 | while (!stoppingToken.IsCancellationRequested) 29 | { 30 | await _bungieClient.ResetService.WaitForNextDailyReset(_delay, stoppingToken); 31 | 32 | _logger.LogInformation("Reset task starting"); 33 | 34 | switch (DateTime.UtcNow.DayOfWeek) 35 | { 36 | case DayOfWeek.Monday: 37 | break; 38 | case DayOfWeek.Tuesday: 39 | // weekly reset 40 | break; 41 | case DayOfWeek.Wednesday: 42 | // gunsmith weird perk reset time 43 | break; 44 | case DayOfWeek.Thursday: 45 | break; 46 | case DayOfWeek.Friday: 47 | // xur & trials 48 | break; 49 | case DayOfWeek.Saturday: 50 | break; 51 | case DayOfWeek.Sunday: 52 | break; 53 | default: 54 | throw new ArgumentOutOfRangeException(); 55 | } 56 | 57 | // do stuff 58 | } 59 | } 60 | catch (Exception e) 61 | { 62 | _logger.LogError(e, "Exception in ResetService"); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /Felicity/Felicity.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 581e6930-6463-4c70-a3cb-9932d090442e 8 | Linux 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | PreserveNewest 38 | 39 | 40 | 41 | 42 | -------------------------------------------------------------------------------- /Felicity/Models/Checkpoints.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using Felicity.Util; 3 | 4 | // ReSharper disable UnusedAutoPropertyAccessor.Global 5 | // ReSharper disable ClassNeverInstantiated.Global 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedType.Global 8 | #pragma warning disable CS8618 9 | 10 | namespace Felicity.Models; 11 | 12 | public class CheckpointParser 13 | { 14 | public static async Task FetchAsync() 15 | { 16 | try 17 | { 18 | return await HttpClientInstance.Instance.GetFromJsonAsync( 19 | "https://d2cp.io/platform/checkpoints?v=2"); 20 | } 21 | catch 22 | { 23 | // ignored 24 | } 25 | 26 | return null; 27 | } 28 | } 29 | 30 | public class Checkpoints 31 | { 32 | [JsonPropertyName("official")] 33 | public Official[]? Official { get; set; } 34 | 35 | [JsonPropertyName("community")] 36 | public object Community { get; set; } 37 | 38 | [JsonPropertyName("alert")] 39 | public Alert Alert { get; set; } 40 | } 41 | 42 | public class Alert 43 | { 44 | [JsonPropertyName("alertActive")] 45 | public bool AlertActive { get; set; } 46 | 47 | [JsonPropertyName("alertText")] 48 | public string AlertText { get; set; } 49 | } 50 | 51 | public class Official 52 | { 53 | [JsonPropertyName("name")] 54 | public string Name { get; set; } 55 | 56 | [JsonPropertyName("activity")] 57 | public string Activity { get; set; } 58 | 59 | [JsonPropertyName("activityHash")] 60 | public long ActivityHash { get; set; } 61 | 62 | [JsonPropertyName("encounter")] 63 | public string Encounter { get; set; } 64 | 65 | [JsonPropertyName("players")] 66 | public int Players { get; set; } 67 | 68 | [JsonPropertyName("maxPlayers")] 69 | public int MaxPlayers { get; set; } 70 | 71 | [JsonPropertyName("difficultyTier")] 72 | public Difficulty DifficultyTier { get; set; } 73 | 74 | [JsonPropertyName("imgURL")] 75 | public string ImgUrl { get; set; } 76 | 77 | [JsonPropertyName("iconURL")] 78 | public string IconUrl { get; set; } 79 | 80 | [JsonPropertyName("displayOrder")] 81 | public int DisplayOrder { get; set; } 82 | } 83 | 84 | public enum Difficulty 85 | { 86 | Normal = 2, 87 | Master = 3 88 | } 89 | -------------------------------------------------------------------------------- /Felicity/Util/Enums/Wishes.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable UnusedAutoPropertyAccessor.Global 2 | 3 | namespace Felicity.Util.Enums; 4 | 5 | internal static class Wishes 6 | { 7 | public static readonly List KnownWishes = new() 8 | { 9 | new Wish { Name = "A wish to feed an addiction.", Description = "Grants an Ethereal Key", Number = 1 }, 10 | new Wish { Name = "A wish for material validation.", Description = "Spawns Glittering Key chest", Number = 2 }, 11 | new Wish 12 | { 13 | Name = "A wish for others to celebrate your success.", Description = "Unlocks Numbers of Power emblem", 14 | Number = 3 15 | }, 16 | new Wish { Name = "A wish to look athletic and elegant.", Description = "Teleport to Shuro-Chi", Number = 4 }, 17 | new Wish { Name = "A wish for a promising future.", Description = "Teleport to Morgeth", Number = 5 }, 18 | new Wish { Name = "A wish to move the hands of time.", Description = "Teleport to Vault", Number = 6 }, 19 | new Wish { Name = "A wish to help a friend in need.", Description = "Teleport to Riven", Number = 7 }, 20 | new Wish { Name = "A wish to stay here forever.", Description = "Plays Hope for the Future song", Number = 8 }, 21 | new Wish { Name = "A wish to stay here forever.", Description = "Enables Failsafe voice lines", Number = 9 }, 22 | new Wish { Name = "A wish to stay here forever.", Description = "Enables Drifter voice lines", Number = 10 }, 23 | new Wish { Name = "A wish to stay here forever.", Description = "Enables Grunt Birthday Party", Number = 11 }, 24 | new Wish 25 | { 26 | Name = "A wish to open your mind to new ideas.", Description = "Adds an effect around the players head", 27 | Number = 12 28 | }, 29 | new Wish 30 | { 31 | Name = "A wish for the means to feed an addiction.", Description = "Enables Petra's Run (flawless)", 32 | Number = 13 33 | }, 34 | new Wish { Name = "A wish for love and support.", Description = "Spawns Corrupted Eggs", Number = 14 }, 35 | new Wish { Name = "This one you shall cherish.", Description = "Undiscovered", Number = 15 } 36 | }; 37 | } 38 | 39 | internal class Wish 40 | { 41 | public string? Name { get; init; } 42 | public string? Description { get; init; } 43 | public int Number { get; init; } 44 | } 45 | -------------------------------------------------------------------------------- /Felicity/Util/ProfileHelper.cs: -------------------------------------------------------------------------------- 1 | using DotNetBungieAPI.Models; 2 | using DotNetBungieAPI.Service.Abstractions; 3 | using Felicity.Models; 4 | 5 | namespace Felicity.Util; 6 | 7 | public abstract class ProfileHelper 8 | { 9 | public static async Task GetRequestedProfile( 10 | string bungieTag, 11 | ulong discordId, 12 | UserDb userDb, 13 | IBungieClient bungieClient) 14 | { 15 | var profile = new ProfileResponse(); 16 | 17 | if (string.IsNullOrEmpty(bungieTag)) 18 | { 19 | var currentUser = userDb.Users.FirstOrDefault(x => x.DiscordId == discordId); 20 | 21 | if (currentUser == null) 22 | { 23 | profile.Error = 24 | "You haven't specified a Bungie name to look up. If you want to use your own account, please register with '/user register' first." 25 | + "Alternatively, you can provide a specific name to search for."; 26 | return profile; 27 | } 28 | 29 | profile.MembershipId = currentUser.DestinyMembershipId; 30 | profile.MembershipType = currentUser.DestinyMembershipType; 31 | profile.BungieName = currentUser.BungieName; 32 | 33 | return profile; 34 | } 35 | 36 | var name = bungieTag.Split("#").First(); 37 | var code = Convert.ToInt16(bungieTag.Split("#").Last()); 38 | 39 | var goodProfile = await BungieApiUtils.GetLatestProfileAsync(bungieClient, name, code); 40 | if (goodProfile == null || goodProfile.MembershipType == BungieMembershipType.None) 41 | { 42 | profile.Error = 43 | $"No profiles found matching `{bungieTag}`.\nThis can happen if no characters are currently on the Bungie account."; 44 | return profile; 45 | } 46 | 47 | profile.MembershipId = goodProfile.MembershipId; 48 | profile.MembershipType = goodProfile.MembershipType; 49 | profile.BungieName = $"{goodProfile.BungieGlobalDisplayName}#{goodProfile.BungieGlobalDisplayNameCode}"; 50 | 51 | return profile; 52 | } 53 | 54 | public class ProfileResponse 55 | { 56 | public string? Error { get; set; } 57 | public long MembershipId { get; set; } 58 | public BungieMembershipType MembershipType { get; set; } 59 | public string? BungieName { get; set; } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /Felicity/Util/BotVariables.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using Discord.WebSocket; 3 | 4 | namespace Felicity.Util; 5 | 6 | public static class BotVariables 7 | { 8 | // TODO: move these to settings 9 | public const ulong BotOwnerId = 684854397871849482; 10 | public const string DiscordInvite = "https://discord.gg/JBBqF6Pw2z"; 11 | public const string BungieBaseUrl = "https://www.bungie.net/"; 12 | 13 | internal const string ErrorMessage = 14 | $"You can report this error either in our [Support Server]({DiscordInvite}) " + 15 | "or by creating a new [Issue](https://github.com/devFelicity/Bot-Frontend/issues/new?assignees=MoonieGZ&labels=bug&template=bug-report.md&title=) on GitHub."; 16 | 17 | internal static bool IsDebug; 18 | internal static string? Version; 19 | 20 | public static SocketTextChannel? DiscordLogChannel { get; set; } 21 | 22 | public static async Task Initialize() 23 | { 24 | if (Debugger.IsAttached) 25 | { 26 | IsDebug = true; 27 | Version = "dev-env"; 28 | } 29 | else 30 | { 31 | var s = await HttpClientInstance.Instance.GetStringAsync( 32 | "https://raw.githubusercontent.com/devFelicity/Bot-Frontend/main/CHANGELOG.md"); 33 | Version = s.Split("# Version: v")[1].Split(" (")[0]; 34 | } 35 | } 36 | 37 | public static class Images 38 | { 39 | public const string AdaVendorLogo = "https://cdn.tryfelicity.one/bungie_assets/VendorAda.png"; 40 | public const string DungeonIcon = "https://cdn.tryfelicity.one/bungie_assets/Dungeon.png"; 41 | public const string FelicityCircle = "https://cdn.tryfelicity.one/images/profile/candle-circle.png"; 42 | public const string FelicitySquare = "https://cdn.tryfelicity.one/images/profile/candle.png"; 43 | public const string GunsmithVendorLogo = "https://cdn.tryfelicity.one/bungie_assets/VendorGunsmith.png"; 44 | public const string JoaquinAvatar = "https://cdn.tryfelicity.one/images/joaquin-avatar.png"; 45 | public const string ModVendorIcon = "https://cdn.tryfelicity.one/bungie_assets/ModVendor.png"; 46 | public const string RaidIcon = "https://cdn.tryfelicity.one/bungie_assets/Raid.png"; 47 | public const string SadFace = "https://cdn.tryfelicity.one/images/peepoSad.png"; 48 | public const string SaintVendorLogo = "https://cdn.tryfelicity.one/bungie_assets/VendorSaint.png"; 49 | public const string XurVendorLogo = "https://cdn.tryfelicity.one/bungie_assets/VendorXur.png"; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/MetricsCommands.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | using DotNetBungieAPI.Extensions; 3 | using DotNetBungieAPI.Models.Destiny; 4 | using DotNetBungieAPI.Models.Destiny.Definitions.Metrics; 5 | using DotNetBungieAPI.Service.Abstractions; 6 | using Felicity.Models; 7 | using Felicity.Util; 8 | 9 | // ReSharper disable UnusedMember.Global 10 | // ReSharper disable UnusedType.Global 11 | 12 | namespace Felicity.DiscordCommands.Interactions; 13 | 14 | [Preconditions.RequireOAuth] 15 | public class MetricsCommands : InteractionModuleBase 16 | { 17 | private readonly IBungieClient _bungieClient; 18 | private readonly UserDb _userDb; 19 | 20 | public MetricsCommands(IBungieClient bungieClient, UserDb userDb) 21 | { 22 | _bungieClient = bungieClient; 23 | _userDb = userDb; 24 | } 25 | 26 | [SlashCommand("metrics", "Fetch metrics from your Destiny profile.")] 27 | public async Task Metrics( 28 | [Autocomplete(typeof(MetricAutocomplete))] [Summary("query", "Specific metric you want to pull values for.")] 29 | uint metricId) 30 | { 31 | if (!await BungieApiUtils.CheckApi(_bungieClient)) 32 | throw new Exception("Bungie API is down or unresponsive."); 33 | 34 | var currentUser = _userDb.Users.FirstOrDefault(x => x.DiscordId == Context.User.Id); 35 | if (currentUser == null) 36 | { 37 | await FollowupAsync("Failed to fetch user profile."); 38 | return; 39 | } 40 | 41 | if (!_bungieClient.Repository.TryGetDestinyDefinition(metricId, 42 | out var metricDefinition)) 43 | { 44 | await FollowupAsync("Failed to fetch metrics."); 45 | return; 46 | } 47 | 48 | var profileMetrics = await _bungieClient.ApiAccess.Destiny2.GetProfile(currentUser.DestinyMembershipType, 49 | currentUser.DestinyMembershipId, new[] 50 | { 51 | DestinyComponentType.Metrics 52 | }, currentUser.GetTokenData()); 53 | 54 | var value = profileMetrics.Response.Metrics.Data.Metrics[metricId].ObjectiveProgress.Progress 55 | ?.FormatUIDisplayValue(profileMetrics.Response.Metrics.Data.Metrics[metricId].ObjectiveProgress.Objective 56 | .GetValueOrNull()!); 57 | 58 | var embed = Embeds.MakeBuilder(); 59 | embed.AddField(metricDefinition.DisplayProperties.Name, value); 60 | embed.AddField("Objective", metricDefinition.TrackingObjective.Select(x => x.ProgressDescription)); 61 | 62 | await FollowupAsync(embed: embed.Build()); 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Text/EmblemTextCommands.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using Discord; 4 | using Discord.Commands; 5 | using DotNetBungieAPI.Models.Destiny; 6 | using DotNetBungieAPI.Models.Destiny.Definitions.InventoryItems; 7 | using DotNetBungieAPI.Service.Abstractions; 8 | using Felicity.Util; 9 | 10 | // ReSharper disable UnusedMember.Global 11 | // ReSharper disable UnusedType.Global 12 | 13 | namespace Felicity.DiscordCommands.Text; 14 | 15 | [Preconditions.RequireBotModerator] 16 | public class EmblemTextCommands : ModuleBase 17 | { 18 | private readonly IBungieClient _client; 19 | 20 | public EmblemTextCommands(IBungieClient client) 21 | { 22 | _client = client; 23 | } 24 | 25 | [Command("storeEmblems")] 26 | public async Task StoreEmblems() 27 | { 28 | var knownEmblems = FetchAllEmblems(); 29 | 30 | await File.WriteAllTextAsync($"Data/emblems/{DateTime.UtcNow:yy-MM-dd}.json", 31 | JsonSerializer.Serialize(knownEmblems)); 32 | 33 | await ReplyAsync($"Saved {knownEmblems.Count} emblems."); 34 | } 35 | 36 | [Command("compareEmblems")] 37 | public async Task CompareEmblems(string originDate) 38 | { 39 | var previousEmblems = 40 | JsonSerializer.Deserialize>(await File.ReadAllTextAsync($"Data/emblems/{originDate}.json")); 41 | var newEmblems = FetchAllEmblems(); 42 | 43 | if (previousEmblems != null) 44 | { 45 | var sb = new StringBuilder(); 46 | 47 | var uniqueEmblems = newEmblems.Except(previousEmblems).ToList(); 48 | foreach (var uniqueEmblem in uniqueEmblems) 49 | if (_client.Repository.TryGetDestinyDefinition(uniqueEmblem, 50 | out var emblemDefinition)) 51 | sb.Append( 52 | $"{emblemDefinition.DisplayProperties.Name}: {emblemDefinition.SecondaryIcon.AbsolutePath}\n"); 53 | 54 | var bytes = new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())); 55 | await Context.Channel.SendFileAsync(new FileAttachment(bytes, "emblemComparison.txt")); 56 | } 57 | } 58 | 59 | private List FetchAllEmblems() 60 | { 61 | var knownEmblems = new List(); 62 | foreach (var itemDefinition in _client.Repository.GetAll()) 63 | { 64 | if (itemDefinition.ItemType != DestinyItemType.Emblem) 65 | continue; 66 | 67 | if (itemDefinition.Redacted) 68 | continue; 69 | 70 | if (!knownEmblems.Contains(itemDefinition.Hash)) 71 | knownEmblems.Add(itemDefinition.Hash); 72 | } 73 | 74 | return knownEmblems; 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /Felicity/Models/User.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using DotNetBungieAPI.Models; 3 | using DotNetBungieAPI.Models.Authorization; 4 | using DotNetBungieAPI.Service.Abstractions; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | // ReSharper disable PropertyCanBeMadeInitOnly.Global 8 | // ReSharper disable UnusedAutoPropertyAccessor.Global 9 | 10 | namespace Felicity.Models; 11 | 12 | public class User 13 | { 14 | [Key] 15 | public ulong DiscordId { get; set; } 16 | 17 | public string OAuthToken { get; set; } = string.Empty; 18 | public DateTime OAuthTokenExpires { get; set; } 19 | public string OAuthRefreshToken { get; set; } = string.Empty; 20 | public DateTime OAuthRefreshExpires { get; set; } 21 | public long BungieMembershipId { get; set; } 22 | public string BungieName { get; set; } = string.Empty; 23 | public long DestinyMembershipId { get; set; } 24 | public BungieMembershipType DestinyMembershipType { get; set; } 25 | } 26 | 27 | public static class UserExtensions 28 | { 29 | public static async Task RefreshToken(this User user, IBungieClient bungieClient, DateTime nowTime) 30 | { 31 | var refreshedUser = await bungieClient.Authorization.RenewToken(user.GetTokenData()); 32 | 33 | user.OAuthToken = refreshedUser.AccessToken; 34 | user.OAuthTokenExpires = nowTime.AddSeconds(refreshedUser.ExpiresIn); 35 | user.OAuthRefreshToken = refreshedUser.RefreshToken; 36 | user.OAuthRefreshExpires = nowTime.AddSeconds(refreshedUser.RefreshExpiresIn); 37 | 38 | return user; 39 | } 40 | 41 | public static AuthorizationTokenData GetTokenData(this User user) 42 | { 43 | return new AuthorizationTokenData 44 | { 45 | AccessToken = user.OAuthToken, 46 | RefreshToken = user.OAuthRefreshToken, 47 | ExpiresIn = (int)(user.OAuthTokenExpires - DateTime.UtcNow).TotalSeconds, 48 | MembershipId = user.BungieMembershipId, 49 | RefreshExpiresIn = (int)(user.OAuthRefreshExpires - DateTime.UtcNow).TotalSeconds, 50 | TokenType = "Bearer" 51 | }; 52 | } 53 | } 54 | 55 | public class UserDb : DbContext 56 | { 57 | private readonly string? _connectionString; 58 | 59 | public UserDb(IConfiguration configuration) 60 | { 61 | _connectionString = configuration.GetConnectionString("MySQLDb"); 62 | } 63 | 64 | // ReSharper disable once AutoPropertyCanBeMadeGetOnly.Global 65 | public DbSet Users { get; set; } = null!; 66 | 67 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 68 | { 69 | var serverVersion = new MariaDbServerVersion(new Version(10, 2, 21)); 70 | if (_connectionString != null) 71 | optionsBuilder.UseMySql(_connectionString, serverVersion, builder => builder.EnableRetryOnFailure()); 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /Felicity/Services/Hosted/CommandsInfoService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.ObjectModel; 2 | using System.Reflection; 3 | using Discord.Interactions; 4 | using Felicity.Models.CommandsInfo; 5 | using Felicity.Services.Hosted.Interfaces; 6 | using Serilog; 7 | using CommandParameterInfo = Felicity.Models.CommandsInfo.CommandParameterInfo; 8 | 9 | namespace Felicity.Services.Hosted; 10 | 11 | public class CommandsInfoService : ICommandsInfoService 12 | { 13 | private readonly Type _baseCommandType = typeof(IInteractionModuleBase); 14 | 15 | public ReadOnlyCollection CommandsInfo { get; private set; } = null!; 16 | 17 | public void Initialize() 18 | { 19 | var commandsInfo = new List(); 20 | var assembly = Assembly.GetAssembly(typeof(CommandsInfoService)); 21 | 22 | if (assembly is null) 23 | { 24 | Log.Error("Assembly failed to populate."); 25 | return; 26 | } 27 | 28 | var assemblyTypes = assembly.GetTypes(); 29 | var commandTypes = assemblyTypes.Where(x => x.IsAssignableTo(_baseCommandType)).ToArray(); 30 | 31 | foreach (var commandType in commandTypes) 32 | { 33 | var groupAttribute = commandType.GetCustomAttribute(); 34 | 35 | if (groupAttribute is null) continue; 36 | 37 | var methods = commandType.GetMethods( 38 | BindingFlags.Instance | 39 | BindingFlags.Public | 40 | BindingFlags.NonPublic); 41 | 42 | foreach (var methodData in methods) 43 | { 44 | var slashCommandAttribute = methodData.GetCustomAttribute(); 45 | if (slashCommandAttribute is null) continue; 46 | 47 | var commandInfo = new CommandInfo( 48 | $"{groupAttribute.Name} {slashCommandAttribute.Name}", 49 | slashCommandAttribute.Description); 50 | 51 | var parameters = methodData.GetParameters(); 52 | foreach (var parameter in parameters) 53 | { 54 | var commandParameterInfo = new CommandParameterInfo(); 55 | var summaryAttribute = parameter.GetCustomAttribute(); 56 | var autoCompleteAttribute = parameter.GetCustomAttribute(); 57 | 58 | if (summaryAttribute is not null) 59 | { 60 | commandParameterInfo.Name = summaryAttribute.Name; 61 | commandParameterInfo.Description = summaryAttribute.Description; 62 | } 63 | else 64 | { 65 | commandParameterInfo.Name = parameter.Name!; 66 | } 67 | 68 | if (autoCompleteAttribute is not null) commandParameterInfo.IsAutocomplete = true; 69 | 70 | commandInfo.ParametersInfo.Add(commandParameterInfo); 71 | } 72 | 73 | commandsInfo.Add(commandInfo); 74 | } 75 | } 76 | 77 | CommandsInfo = new ReadOnlyCollection(commandsInfo); 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/PbCommands.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using Discord.Interactions; 3 | using DotNetBungieAPI.Extensions; 4 | using DotNetBungieAPI.HashReferences; 5 | using DotNetBungieAPI.Models.Destiny; 6 | using DotNetBungieAPI.Service.Abstractions; 7 | using Felicity.Models; 8 | using Felicity.Util; 9 | 10 | // ReSharper disable UnusedType.Global 11 | // ReSharper disable UnusedMember.Global 12 | 13 | namespace Felicity.DiscordCommands.Interactions; 14 | 15 | [Preconditions.RequireOAuth] 16 | [Group("pb", "Gets your personal best times for each category.")] 17 | public class PbCommands : InteractionModuleBase 18 | { 19 | private readonly IBungieClient _bungieClient; 20 | private readonly UserDb _userDb; 21 | 22 | public PbCommands(IBungieClient bungieClient, UserDb userDb) 23 | { 24 | _bungieClient = bungieClient; 25 | _userDb = userDb; 26 | } 27 | 28 | [SlashCommand("raids", "Gets your fastest raids.")] 29 | public async Task PbRaids() 30 | { 31 | var currentUser = _userDb.Users.FirstOrDefault(x => x.DiscordId == Context.User.Id); 32 | if (currentUser == null) 33 | { 34 | await FollowupAsync("Failed to fetch user profile."); 35 | return; 36 | } 37 | 38 | var profileMetrics = await _bungieClient.ApiAccess.Destiny2.GetProfile(currentUser.DestinyMembershipType, 39 | currentUser.DestinyMembershipId, new[] 40 | { 41 | DestinyComponentType.Metrics 42 | }, currentUser.GetTokenData()); 43 | 44 | var value = new StringBuilder(); 45 | var metricList = new List> 46 | { 47 | new("Last Wish", DefinitionHashes.Metrics.LastWishTimeTrial_552340969), 48 | new("Garden of Salvation", DefinitionHashes.Metrics.KingsFallTimeTrial_399420098), 49 | new("Deep Stone Crypt", DefinitionHashes.Metrics.DeepStoneCryptTimeTrial_3679202587), 50 | new("Vault of Glass", DefinitionHashes.Metrics.VaultofGlassTimeTrial_905219689), 51 | new("Vow of the Disciple", DefinitionHashes.Metrics.VowoftheDiscipleTimeTrial_3775579868), 52 | new("King's Fall", DefinitionHashes.Metrics.KingsFallTimeTrial_399420098), 53 | new("Root of Nightmares", DefinitionHashes.Metrics.RootofNightmaresTimeTrial_58319253) 54 | }; 55 | 56 | foreach (var metric in metricList) 57 | { 58 | var time = profileMetrics.Response.Metrics.Data.Metrics[metric.Value].ObjectiveProgress.Progress 59 | ?.FormatUIDisplayValue(profileMetrics.Response.Metrics.Data.Metrics[metric.Value].ObjectiveProgress 60 | .Objective.GetValueOrNull()!); 61 | value.Append($"> `{time}` - **{metric.Key}**\n"); 62 | } 63 | 64 | var embed = Embeds.MakeBuilder(); 65 | embed.Title = $"Personal best clear times for {currentUser.BungieName}"; 66 | embed.Description = 67 | "⚠️ These values are only what in-game stat trackers show, real times as well as checkpoint vs full clears and lowmans will be available in a future update.\n\n" 68 | + value; 69 | 70 | await FollowupAsync(embed: embed.Build()); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /.all-contributorsrc: -------------------------------------------------------------------------------- 1 | { 2 | "files": [ 3 | "README.md" 4 | ], 5 | "imageSize": 100, 6 | "contributorsPerLine": 7, 7 | "contributorsSortAlphabetically": false, 8 | "badgeTemplate": "[![All Contributors](https://img.shields.io/badge/all_contributors-<%= contributors.length %>-orange.svg?style=flat-square)](#contributors)", 9 | "skipCi": true, 10 | "contributors": [ 11 | { 12 | "login": "axsLeaf", 13 | "name": "Willow", 14 | "avatar_url": "https://avatars.githubusercontent.com/u/1693101?v=4", 15 | "profile": "https://leafhub.dev", 16 | "contributions": [ 17 | "code", 18 | "financial" 19 | ] 20 | }, 21 | { 22 | "login": "EndGameGl", 23 | "name": "MeGl", 24 | "avatar_url": "https://avatars.githubusercontent.com/u/54992889?v=4", 25 | "profile": "https://github.com/EndGameGl", 26 | "contributions": [ 27 | "code", 28 | "mentoring" 29 | ] 30 | }, 31 | { 32 | "login": "Zempp", 33 | "name": "Zempp", 34 | "avatar_url": "https://avatars.githubusercontent.com/u/90584529?v=4", 35 | "profile": "https://www.bungie.net/7/en/User/Profile/254/10910315?bgn=Zempp", 36 | "contributions": [ 37 | "data" 38 | ] 39 | }, 40 | { 41 | "login": "calmqq", 42 | "name": "calmqq", 43 | "avatar_url": "https://avatars.githubusercontent.com/u/49577234?v=4", 44 | "profile": "https://github.com/calmqq", 45 | "contributions": [ 46 | "data" 47 | ] 48 | }, 49 | { 50 | "login": "TheLastJoaquin", 51 | "name": "TheLastJoaquin", 52 | "avatar_url": "https://avatars.githubusercontent.com/u/108595663?v=4", 53 | "profile": "https://github.com/TheLastJoaquin", 54 | "contributions": [ 55 | "data" 56 | ] 57 | }, 58 | { 59 | "login": "Subhaven", 60 | "name": "Subhaven", 61 | "avatar_url": "https://avatars.githubusercontent.com/u/30436380?v=4", 62 | "profile": "https://github.com/Subhaven", 63 | "contributions": [ 64 | "design" 65 | ] 66 | }, 67 | { 68 | "login": "quiffboy", 69 | "name": "Barry Briggs", 70 | "avatar_url": "https://avatars.githubusercontent.com/u/11392094?v=4", 71 | "profile": "https://github.com/quiffboy", 72 | "contributions": [ 73 | "financial" 74 | ] 75 | }, 76 | { 77 | "login": "gothfemme", 78 | "name": "Kat Michaela", 79 | "avatar_url": "https://avatars.githubusercontent.com/u/42180996?v=4", 80 | "profile": "http://gothfem.me", 81 | "contributions": [ 82 | "tool", 83 | "mentoring" 84 | ] 85 | }, 86 | { 87 | "login": "camohiddendj", 88 | "name": "camo", 89 | "avatar_url": "https://avatars.githubusercontent.com/u/11087140?v=4", 90 | "profile": "https://d2checkpoint.com/", 91 | "contributions": [ 92 | "financial", 93 | "infra" 94 | ] 95 | } 96 | ], 97 | "projectName": "Bot-Frontend", 98 | "projectOwner": "devFelicity", 99 | "repoType": "github", 100 | "repoHost": "https://github.com", 101 | "commitType": "docs", 102 | "commitConvention": "angular" 103 | } 104 | -------------------------------------------------------------------------------- /Felicity/Util/Preconditions.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.Interactions; 3 | using DotNetBungieAPI.Service.Abstractions; 4 | using Felicity.Models; 5 | using Serilog; 6 | 7 | namespace Felicity.Util; 8 | 9 | // ReSharper disable once ClassNeverInstantiated.Global 10 | public class Preconditions 11 | { 12 | public class RequireBotModerator : PreconditionAttribute 13 | { 14 | public override async Task CheckRequirementsAsync( 15 | IInteractionContext context, 16 | ICommandInfo commandInfo, 17 | IServiceProvider services) 18 | { 19 | if (context.User.Id == BotVariables.BotOwnerId) 20 | return PreconditionResult.FromSuccess(); 21 | 22 | if (context.User is IGuildUser { GuildPermissions.ManageGuild: true }) 23 | return PreconditionResult.FromSuccess(); 24 | 25 | if (context.Guild.OwnerId == context.User.Id) 26 | return PreconditionResult.FromSuccess(); 27 | 28 | const string msg = "You are not a bot moderator for this server."; 29 | 30 | await context.Interaction.RespondAsync(msg); 31 | 32 | return PreconditionResult.FromError(msg); 33 | } 34 | } 35 | 36 | public class RequireOAuth : PreconditionAttribute 37 | { 38 | public override async Task CheckRequirementsAsync( 39 | IInteractionContext context, 40 | ICommandInfo commandInfo, 41 | IServiceProvider services) 42 | { 43 | await context.Interaction.DeferAsync(); 44 | 45 | var dbSet = services.GetService(); 46 | var user = dbSet?.Users.FirstOrDefault(x => x.DiscordId == context.User.Id); 47 | var nowTime = DateTime.UtcNow; 48 | 49 | var errorEmbed = Embeds.MakeErrorEmbed(); 50 | 51 | if (user is not null) 52 | { 53 | if (user.OAuthRefreshExpires < nowTime) 54 | errorEmbed.Description = 55 | "Your information has expired and needs to be refreshed.\n" + 56 | "Please run `/user register` and follow the instructions."; 57 | 58 | if (user.OAuthTokenExpires < nowTime) 59 | { 60 | user = await user.RefreshToken(services.GetService()!, nowTime); 61 | 62 | Log.Information($"Refreshed token for {user.BungieName}."); 63 | 64 | await dbSet?.SaveChangesAsync()!; 65 | } 66 | 67 | if (user.BungieMembershipId == 0) 68 | errorEmbed.Description = 69 | $"Your registration data is invalid, please run `/user register` again.\nIf the issue persists, {BotVariables.ErrorMessage}"; 70 | } 71 | else 72 | { 73 | errorEmbed.Description = 74 | "This command requires you to be registered to provide user information to the API.\n" + 75 | "Please use `/user register` and try again."; 76 | } 77 | 78 | if (string.IsNullOrEmpty(errorEmbed.Description)) 79 | return PreconditionResult.FromSuccess(); 80 | 81 | await context.Interaction.FollowupAsync(embed: errorEmbed.Build()); 82 | return PreconditionResult.FromError(errorEmbed.Description); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Felicity/Controllers/BungieAuthController.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Claims; 2 | using DotNetBungieAPI.Models; 3 | using DotNetBungieAPI.Models.User; 4 | using DotNetBungieAPI.Service.Abstractions; 5 | using Felicity.Models; 6 | using Felicity.Services; 7 | using Microsoft.AspNetCore.Authentication; 8 | using Microsoft.AspNetCore.Mvc; 9 | 10 | // ReSharper disable RouteTemplates.ActionRoutePrefixCanBeExtractedToControllerRoute 11 | 12 | namespace Felicity.Controllers; 13 | 14 | [Route("auth")] 15 | [ApiController] 16 | public class BungieAuthController : ControllerBase 17 | { 18 | private readonly IBungieClient _bungieClient; 19 | private readonly UserDb _dbContext; 20 | 21 | public BungieAuthController(UserDb userDbContext, IBungieClient bungieApiClient) 22 | { 23 | _dbContext = userDbContext; 24 | _bungieClient = bungieApiClient; 25 | } 26 | 27 | [HttpGet("bungie_net/{discordId}")] 28 | public async Task RedirectToBungieNet(ulong discordId) 29 | { 30 | await HttpContext.ChallengeAsync( 31 | "BungieNet", 32 | new AuthenticationProperties 33 | { 34 | RedirectUri = $"auth/bungie_net/{discordId}/post_callback/" 35 | }); 36 | } 37 | 38 | [HttpGet("bungie_net/{discordId}/post_callback")] 39 | public async Task HandleAuthPostCallback(ulong discordId) 40 | { 41 | var claim = HttpContext.User.Claims.FirstOrDefault(x => x.Type == ClaimTypes.NameIdentifier); 42 | if (claim is null) 43 | return RedirectPermanent("https://tryfelicity.one/auth_failure"); 44 | 45 | var id = long.Parse(claim.Value); 46 | if (!BungieAuthCacheService.GetByIdAndRemove(id, out var context)) 47 | return RedirectPermanent("https://tryfelicity.one/auth_failure"); 48 | 49 | var token = context.Token; 50 | 51 | var nowTime = DateTime.UtcNow; 52 | var baseTime = new DateTime(nowTime.Year, nowTime.Month, nowTime.Day, 53 | nowTime.Hour, nowTime.Minute, nowTime.Second); 54 | 55 | var latestProfile = new DestinyProfileUserInfoCard(); 56 | 57 | var user = _dbContext.Users.FirstOrDefault(x => x.DiscordId == discordId); 58 | var addUser = false; 59 | 60 | if (user == null) 61 | { 62 | addUser = true; 63 | 64 | user = new User 65 | { 66 | DiscordId = discordId 67 | }; 68 | } 69 | 70 | user.BungieMembershipId = token.MembershipId; 71 | user.OAuthToken = token.AccessToken; 72 | user.OAuthRefreshToken = token.RefreshToken; 73 | user.OAuthTokenExpires = baseTime.AddSeconds(token.ExpiresIn); 74 | user.OAuthRefreshExpires = baseTime.AddSeconds(token.RefreshExpiresIn); 75 | 76 | var linkedProfiles = 77 | await _bungieClient.ApiAccess.Destiny2.GetLinkedProfiles(BungieMembershipType.BungieNext, 78 | user.BungieMembershipId, true); 79 | 80 | foreach (var potentialProfile in linkedProfiles.Response.Profiles) 81 | if (potentialProfile.DateLastPlayed > latestProfile.DateLastPlayed) 82 | latestProfile = potentialProfile; 83 | 84 | user.BungieName = latestProfile.BungieGlobalDisplayName + "#" + latestProfile.BungieGlobalDisplayNameCode; 85 | user.DestinyMembershipId = latestProfile.MembershipId; 86 | user.DestinyMembershipType = latestProfile.MembershipType; 87 | 88 | if (addUser) 89 | _dbContext.Users.Add(user); 90 | 91 | await _dbContext.SaveChangesAsync(); 92 | 93 | return RedirectPermanent("https://tryfelicity.one/auth_success"); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/CheckpointCommands.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using Discord; 3 | using Discord.Interactions; 4 | using Felicity.Models; 5 | using Felicity.Util; 6 | 7 | // ReSharper disable UnusedMember.Global 8 | // ReSharper disable UnusedType.Global 9 | 10 | namespace Felicity.DiscordCommands.Interactions; 11 | 12 | public class CheckpointCommands : InteractionModuleBase 13 | { 14 | [SlashCommand("checkpoint-list", "List all available checkpoints and their status.")] 15 | public async Task CheckpointList() 16 | { 17 | await DeferAsync(); 18 | 19 | var checkpointList = await CheckpointParser.FetchAsync(); 20 | 21 | var embed = Embeds.MakeBuilder(); 22 | 23 | embed.Author = new EmbedAuthorBuilder 24 | { 25 | Name = "D2Checkpoint.com", 26 | IconUrl = "https://cdn.tryfelicity.one/images/d2cp.png", 27 | Url = "https://d2checkpoint.com" 28 | }; 29 | 30 | embed.ThumbnailUrl = 31 | "https://www.bungie.net/common/destiny2_content/icons/8b1bfd1c1ce1cab51d23c78235a6e067.png"; 32 | 33 | embed.Title = "All available checkpoints:"; 34 | 35 | var sb = new StringBuilder(); 36 | 37 | if (checkpointList?.Official != null) 38 | foreach (var officialCp in checkpointList.Official) 39 | sb.Append( 40 | $"{Format.Bold(officialCp.Activity)} - {officialCp.Encounter} [{officialCp.Players}/{officialCp.MaxPlayers}]" + 41 | $"\n{Format.Code($"/join {officialCp.Name}")}\n\n"); 42 | else 43 | sb.Append("Checkpoint list unavailable."); 44 | 45 | embed.Description = sb.ToString(); 46 | 47 | await FollowupAsync(embed: embed.Build()); 48 | } 49 | 50 | [SlashCommand("checkpoint", "Find a checkpoint and join it.")] 51 | public async Task Checkpoint( 52 | [Autocomplete(typeof(CheckpointAutocomplete))] [Summary("name", "Activity/Encounter to search for:")] 53 | int displayOrder) 54 | { 55 | await DeferAsync(); 56 | 57 | var checkpointList = await CheckpointParser.FetchAsync(); 58 | 59 | // ReSharper disable once MergeSequentialChecks 60 | var failed = checkpointList == null && checkpointList?.Official != null; 61 | 62 | var currentCheckpoint = checkpointList?.Official?.FirstOrDefault(x => x.DisplayOrder == displayOrder); 63 | if (currentCheckpoint == null) failed = true; 64 | 65 | if (failed) 66 | { 67 | var errEmbed = Embeds.MakeErrorEmbed(); 68 | errEmbed.Description = "Failed to fetch checkpoint list."; 69 | 70 | await FollowupAsync(embed: errEmbed.Build()); 71 | return; 72 | } 73 | 74 | var embed = Embeds.MakeBuilder(); 75 | 76 | embed.Author = new EmbedAuthorBuilder 77 | { 78 | Name = "D2Checkpoint.com", 79 | IconUrl = "https://cdn.tryfelicity.one/images/d2cp.png", 80 | Url = "https://d2checkpoint.com" 81 | }; 82 | 83 | #pragma warning disable CS8602 84 | embed.ThumbnailUrl = currentCheckpoint.IconUrl; 85 | embed.ImageUrl = currentCheckpoint.ImgUrl; 86 | embed.Title = $"{currentCheckpoint.Activity} - {currentCheckpoint.Encounter}"; 87 | embed.Description = Format.Code($"/join {currentCheckpoint.Name}"); 88 | embed.AddField("Players", $"{currentCheckpoint.Players}/{currentCheckpoint.MaxPlayers}", true); 89 | embed.AddField("Difficulty", currentCheckpoint.DifficultyTier.ToString(), true); 90 | #pragma warning restore CS8602 91 | 92 | await FollowupAsync(embed: embed.Build()); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Felicity/Models/RecommendedRolls.cs: -------------------------------------------------------------------------------- 1 | // ReSharper disable CollectionNeverUpdated.Global 2 | // ReSharper disable ClassNeverInstantiated.Global 3 | // ReSharper disable CollectionNeverQueried.Global 4 | // ReSharper disable PropertyCanBeMadeInitOnly.Global 5 | // ReSharper disable UnusedAutoPropertyAccessor.Global 6 | // ReSharper disable AutoPropertyCanBeMadeGetOnly.Global 7 | // ReSharper disable UnusedMember.Global 8 | 9 | using System.Text.Json; 10 | using System.Text.Json.Serialization; 11 | 12 | namespace Felicity.Models; 13 | 14 | public partial class NewWeaponRoll 15 | { 16 | [JsonPropertyName("weaponRolls")] 17 | public List WeaponRolls { get; set; } 18 | } 19 | 20 | public class WeaponRoll 21 | { 22 | [JsonPropertyName("weaponHash")] 23 | public uint WeaponHash { get; set; } 24 | 25 | [JsonPropertyName("authorId")] 26 | public int AuthorId { get; set; } 27 | 28 | [JsonPropertyName("source")] 29 | public int Source { get; set; } 30 | 31 | [JsonPropertyName("notes")] 32 | public string? Notes { get; set; } 33 | 34 | [JsonPropertyName("perks")] 35 | public List Perks { get; set; } 36 | 37 | [JsonPropertyName("canDrop")] 38 | public bool CanDrop { get; set; } 39 | } 40 | 41 | public partial class NewWeaponRoll 42 | { 43 | public static NewWeaponRoll FromJson(string json) 44 | { 45 | return JsonSerializer.Deserialize(json)!; 46 | } 47 | 48 | public static string ToJson(NewWeaponRoll self) 49 | { 50 | return JsonSerializer.Serialize(self); 51 | } 52 | } 53 | 54 | public class RecommendedRolls 55 | { 56 | public List? Authors { get; set; } 57 | public List? PvE { get; set; } 58 | public List? PvP { get; set; } 59 | } 60 | 61 | public class Roll 62 | { 63 | public int AuthorId { get; set; } 64 | public bool CanDrop { get; set; } 65 | public WeaponSource Source { get; set; } 66 | public string? Reason { get; set; } 67 | public string? WeaponName { get; set; } 68 | public uint WeaponId { get; set; } 69 | public List WeaponPerks { get; set; } = new(); 70 | } 71 | 72 | public class Author 73 | { 74 | public int Id { get; set; } 75 | public string? Name { get; set; } 76 | public string? Image { get; set; } 77 | public string? Url { get; set; } 78 | } 79 | 80 | public enum WeaponSource 81 | { 82 | WorldDrop, 83 | LastWish, 84 | GardenOfSalvation, 85 | DeepStoneCrypt, 86 | VaultOfGlass, 87 | VowOfTheDisciple, 88 | GrandmasterNightfall, 89 | ShatteredThrone, 90 | PitOfHeresy, 91 | Prophecy, 92 | GraspOfAvarice, 93 | Duality, 94 | TrialsOfOsiris, 95 | Strikes, 96 | IronBanner, 97 | Crucible, 98 | Gambit, 99 | Moon, 100 | Europa, 101 | XurEternity, 102 | ThroneWorld, 103 | Leviathan, 104 | Event, 105 | KingsFall, 106 | Lightfall, 107 | RootOfNightmares, 108 | GhostsOfTheDeep, 109 | CrotasEnd, 110 | SalvationsEdge, 111 | WarlordsRuin, 112 | VespersHost, 113 | SunderedDoctrine, 114 | PaleHeart, 115 | Seasonal = 100, 116 | Unknown = 999 117 | } 118 | 119 | public static class ProcessRollData 120 | { 121 | private const string JsonFile = "Data/weaponRolls.json"; 122 | 123 | public static async Task FromJsonAsync() 124 | { 125 | if (!File.Exists(JsonFile)) 126 | return null; 127 | 128 | await using var stream = File.OpenRead(JsonFile); 129 | return await JsonSerializer.DeserializeAsync(stream); 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /Felicity/Models/Caches/GunsmithCache.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.WebSocket; 3 | using DotNetBungieAPI.Extensions; 4 | using DotNetBungieAPI.HashReferences; 5 | using DotNetBungieAPI.Models.Destiny; 6 | using DotNetBungieAPI.Service.Abstractions; 7 | using Felicity.Util; 8 | using Serilog; 9 | 10 | namespace Felicity.Models.Caches; 11 | 12 | public class GunsmithCache 13 | { 14 | public DateTime InventoryExpires { get; set; } = DateTime.UtcNow; 15 | public List GunsmithInventory { get; init; } = new(); 16 | } 17 | 18 | public static class ProcessGunsmithData 19 | { 20 | public static Embed BuildEmbed(GunsmithCache self, BaseSocketClient discordClient) 21 | { 22 | var embed = Embeds.MakeBuilder(); 23 | embed.Title = "Banshee-44"; 24 | embed.ThumbnailUrl = BotVariables.Images.GunsmithVendorLogo; 25 | embed.Description = 26 | "Banshee-44 has lived many lives. As master weaponsmith for the Tower, he supplies Guardians with only the best."; 27 | 28 | var weapons = WeaponHelper.PopulateWeaponPerks(discordClient, self.GunsmithInventory, false); 29 | embed.AddField("Weapons", weapons, true); 30 | 31 | return embed.Build(); 32 | } 33 | 34 | public static async Task FetchInventory(User oauth, IBungieClient bungieClient) 35 | { 36 | /*var path = $"Data/gsCache-{lg}.json"; 37 | 38 | if (File.Exists(path)) 39 | { 40 | gsCache = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path)); 41 | 42 | if (gsCache != null && gsCache.InventoryExpires < DateTime.UtcNow) 43 | File.Delete(path); 44 | else 45 | return gsCache; 46 | }*/ 47 | 48 | var characterIdTask = await bungieClient.ApiAccess.Destiny2.GetProfile(oauth.DestinyMembershipType, 49 | oauth.DestinyMembershipId, new[] 50 | { 51 | DestinyComponentType.Characters 52 | }); 53 | 54 | var vendorData = await bungieClient.ApiAccess.Destiny2.GetVendor(oauth.DestinyMembershipType, 55 | oauth.DestinyMembershipId, characterIdTask.Response.Characters.Data.Keys.First(), 56 | DefinitionHashes.Vendors.Banshee44_672118013, new[] 57 | { 58 | DestinyComponentType.ItemSockets, 59 | DestinyComponentType.VendorCategories, 60 | DestinyComponentType.VendorSales 61 | }, oauth.GetTokenData()); 62 | 63 | if (vendorData.Response.Sales.Data.Keys.Count == 0) 64 | { 65 | Log.Error("Gunsmith inventory lookup failed."); 66 | return null; 67 | } 68 | 69 | var weaponList = new List(); 70 | 71 | foreach (var itemIndex in vendorData.Response.Categories.Data.Categories.ElementAt(2).ItemIndexes) 72 | { 73 | if (!vendorData.Response.Sales.Data[itemIndex].Item.TryGetDefinition(out var itemDefinition)) 74 | continue; 75 | 76 | var weapon = new Weapon 77 | { 78 | Name = itemDefinition!.DisplayProperties.Name, 79 | DestinyItemType = itemDefinition.ItemSubType, 80 | WeaponId = itemDefinition.Hash, 81 | Perks = await WeaponHelper.BuildPerks(bungieClient, ItemTierType.Superior, 82 | vendorData.Response.ItemComponents.Sockets.Data[itemIndex]) 83 | }; 84 | 85 | weaponList.Add(weapon); 86 | } 87 | 88 | var gsCache = new GunsmithCache 89 | { 90 | InventoryExpires = ResetUtils.GetNextDailyReset(), 91 | GunsmithInventory = weaponList 92 | }; 93 | 94 | // await File.WriteAllTextAsync($"Data/gsCache-{lg}.json", JsonSerializer.Serialize(gsCache)); 95 | 96 | return gsCache; 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Felicity/Util/BungieAPIUtils.cs: -------------------------------------------------------------------------------- 1 | using DotNetBungieAPI.Models; 2 | using DotNetBungieAPI.Models.Authorization; 3 | using DotNetBungieAPI.Models.Requests; 4 | using DotNetBungieAPI.Models.User; 5 | using DotNetBungieAPI.Service.Abstractions; 6 | using Felicity.Models; 7 | using Serilog; 8 | 9 | // ReSharper disable UnusedMember.Global 10 | // ReSharper disable UnusedType.Global 11 | 12 | namespace Felicity.Util; 13 | 14 | public static class BungieApiUtils 15 | { 16 | public static async Task CheckApi(IBungieClient client) 17 | { 18 | var apiInfo = await client.ApiAccess.Misc.GetCommonSettings(); 19 | 20 | try 21 | { 22 | if (apiInfo.Response.Systems.TryGetValue("Destiny2", out var d2Value)) 23 | if (d2Value.IsEnabled) 24 | return true; 25 | } 26 | catch (Exception e) 27 | { 28 | Log.Error(e, "API check failure"); 29 | } 30 | 31 | return false; 32 | } 33 | 34 | public static async Task GetLatestProfile( 35 | IBungieClient client, 36 | long membershipId, 37 | BungieMembershipType membershipType) 38 | { 39 | var result = new DestinyProfileUserInfoCard(); 40 | 41 | var linkedProfiles = await client.ApiAccess.Destiny2.GetLinkedProfiles(membershipType, membershipId, true); 42 | 43 | foreach (var potentialProfile in linkedProfiles.Response.Profiles) 44 | if (potentialProfile.DateLastPlayed > result.DateLastPlayed) 45 | result = potentialProfile; 46 | 47 | return result; 48 | } 49 | 50 | public static async Task GetLatestProfileAsync( 51 | IBungieClient bungieClient, 52 | string bungieName, 53 | short bungieCode) 54 | { 55 | var userInfoCard = await bungieClient.ApiAccess.Destiny2.SearchDestinyPlayerByBungieName( 56 | BungieMembershipType.All, 57 | new ExactSearchRequest 58 | { 59 | DisplayName = bungieName, 60 | DisplayNameCode = bungieCode 61 | }); 62 | 63 | var response = userInfoCard.Response; 64 | 65 | // ReSharper disable once ConditionIsAlwaysTrueOrFalseAccordingToNullableAPIContract 66 | if (response == null || response.Count == 0) 67 | return null; 68 | 69 | return await GetLatestProfile(bungieClient, response.First().MembershipId, response.First().MembershipType); 70 | } 71 | 72 | public static async Task ForceRefresh(IBungieClient client, UserDb userDb) 73 | { 74 | var nowTime = DateTime.UtcNow; 75 | 76 | foreach (var user in userDb.Users) 77 | try 78 | { 79 | var token = new AuthorizationTokenData 80 | { 81 | AccessToken = user.OAuthToken, 82 | RefreshToken = user.OAuthRefreshToken, 83 | RefreshExpiresIn = (int)(user.OAuthRefreshExpires - nowTime).TotalSeconds, 84 | MembershipId = user.BungieMembershipId, 85 | TokenType = "Bearer" 86 | }; 87 | var refreshedUser = await client.Authorization.RenewToken(token); 88 | 89 | user.OAuthToken = refreshedUser.AccessToken; 90 | user.OAuthTokenExpires = nowTime.AddSeconds(refreshedUser.ExpiresIn); 91 | user.OAuthRefreshToken = refreshedUser.RefreshToken; 92 | user.OAuthRefreshExpires = nowTime.AddSeconds(refreshedUser.RefreshExpiresIn); 93 | } 94 | catch (Exception e) 95 | { 96 | Log.Logger.Error(e, "Failed to refresh token for {BungieName}", user.BungieName); 97 | } 98 | 99 | await userDb.SaveChangesAsync(); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | build/ 21 | bld/ 22 | [Bb]in/ 23 | [Oo]bj/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | 28 | # MSTest test Results 29 | [Tt]est[Rr]esult*/ 30 | [Bb]uild[Ll]og.* 31 | 32 | # NUNIT 33 | *.VisualState.xml 34 | TestResult.xml 35 | 36 | # Build Results of an ATL Project 37 | [Dd]ebugPS/ 38 | [Rr]eleasePS/ 39 | dlldata.c 40 | 41 | # DNX 42 | project.lock.json 43 | artifacts/ 44 | 45 | *_i.c 46 | *_p.c 47 | *_i.h 48 | *.ilk 49 | *.meta 50 | *.obj 51 | *.pch 52 | *.pdb 53 | *.pgc 54 | *.pgd 55 | *.rsp 56 | *.sbr 57 | *.tlb 58 | *.tli 59 | *.tlh 60 | *.tmp 61 | *.tmp_proj 62 | *.log 63 | *.vspscc 64 | *.vssscc 65 | .builds 66 | *.pidb 67 | *.svclog 68 | *.scc 69 | 70 | # Chutzpah Test files 71 | _Chutzpah* 72 | 73 | # Visual C++ cache files 74 | ipch/ 75 | *.aps 76 | *.ncb 77 | *.opensdf 78 | *.sdf 79 | *.cachefile 80 | 81 | # Visual Studio profiler 82 | *.psess 83 | *.vsp 84 | *.vspx 85 | 86 | # TFS 2012 Local Workspace 87 | $tf/ 88 | 89 | # Guidance Automation Toolkit 90 | *.gpState 91 | 92 | # ReSharper is a .NET coding add-in 93 | _ReSharper*/ 94 | *.[Rr]e[Ss]harper 95 | *.DotSettings.user 96 | 97 | # JustCode is a .NET coding add-in 98 | .JustCode 99 | 100 | # TeamCity is a build add-in 101 | _TeamCity* 102 | 103 | # DotCover is a Code Coverage Tool 104 | *.dotCover 105 | 106 | # NCrunch 107 | _NCrunch_* 108 | .*crunch*.local.xml 109 | 110 | # MightyMoose 111 | *.mm.* 112 | AutoTest.Net/ 113 | 114 | # Web workbench (sass) 115 | .sass-cache/ 116 | 117 | # Installshield output folder 118 | [Ee]xpress/ 119 | 120 | # DocProject is a documentation generator add-in 121 | DocProject/buildhelp/ 122 | DocProject/Help/*.HxT 123 | DocProject/Help/*.HxC 124 | DocProject/Help/*.hhc 125 | DocProject/Help/*.hhk 126 | DocProject/Help/*.hhp 127 | DocProject/Help/Html2 128 | DocProject/Help/html 129 | 130 | # Click-Once directory 131 | publish/ 132 | 133 | # Publish Web Output 134 | *.[Pp]ublish.xml 135 | *.azurePubxml 136 | ## TODO: Comment the next line if you want to checkin your 137 | ## web deploy settings but do note that will include unencrypted 138 | ## passwords 139 | #*.pubxml 140 | 141 | *.publishproj 142 | 143 | # NuGet Packages 144 | *.nupkg 145 | # The packages folder can be ignored because of Package Restore 146 | **/packages/* 147 | # except build/, which is used as an MSBuild target. 148 | !**/packages/build/ 149 | # Uncomment if necessary however generally it will be regenerated when needed 150 | #!**/packages/repositories.config 151 | 152 | # Windows Azure Build Output 153 | csx/ 154 | *.build.csdef 155 | 156 | # Windows Store app package directory 157 | AppPackages/ 158 | 159 | # Visual Studio cache files 160 | # files ending in .cache can be ignored 161 | *.[Cc]ache 162 | # but keep track of directories ending in .cache 163 | !*.[Cc]ache/ 164 | 165 | # Others 166 | ClientBin/ 167 | [Ss]tyle[Cc]op.* 168 | ~$* 169 | *~ 170 | *.dbmdl 171 | *.dbproj.schemaview 172 | *.publishsettings 173 | node_modules/ 174 | orleans.codegen.cs 175 | 176 | # RIA/Silverlight projects 177 | Generated_Code/ 178 | 179 | # Backup & report files from converting an old project file 180 | # to a newer Visual Studio version. Backup files are not needed, 181 | # because we have git ;-) 182 | _UpgradeReport_Files/ 183 | Backup*/ 184 | UpgradeLog*.XML 185 | UpgradeLog*.htm 186 | 187 | # SQL Server files 188 | *.mdf 189 | *.ldf 190 | 191 | # Business Intelligence projects 192 | *.rdl.data 193 | *.bim.layout 194 | *.bim_*.settings 195 | 196 | # Microsoft Fakes 197 | FakesAssemblies/ 198 | 199 | # Node.js Tools for Visual Studio 200 | .ntvs_analysis.dat 201 | 202 | # Visual Studio 6 build log 203 | *.plg 204 | 205 | # Visual Studio 6 workspace options file 206 | *.opt 207 | 208 | # LightSwitch generated files 209 | GeneratedArtifacts/ 210 | _Pvt_Extensions/ 211 | ModelManifest.xml 212 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/LootCommands.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.Interactions; 3 | using DotNetBungieAPI.Models.Destiny.Definitions.InventoryItems; 4 | using DotNetBungieAPI.Service.Abstractions; 5 | using Felicity.Util; 6 | using Felicity.Util.Enums; 7 | using ActivityType = Felicity.Util.Enums.ActivityType; 8 | 9 | // ReSharper disable UnusedType.Global 10 | // ReSharper disable UnusedMember.Global 11 | 12 | namespace Felicity.DiscordCommands.Interactions; 13 | 14 | public class LootCommands : InteractionModuleBase 15 | { 16 | private readonly IBungieClient _bungieClient; 17 | 18 | public LootCommands(IBungieClient bungieClient) 19 | { 20 | _bungieClient = bungieClient; 21 | } 22 | 23 | [SlashCommand("loot-table", "Get loot tables from dungeons or raids.")] 24 | public async Task LootTable([Autocomplete(typeof(LootTableAutocomplete))] string lootTable) 25 | { 26 | await DeferAsync(); 27 | 28 | var requestedLootTable = LootTables.KnownTables.FirstOrDefault(x => x.Name == lootTable); 29 | if (requestedLootTable?.Loot == null) 30 | { 31 | var errorEmbed = Embeds.MakeErrorEmbed(); 32 | errorEmbed.Description = "Unable to find requested loot table."; 33 | 34 | await FollowupAsync(embed: errorEmbed.Build()); 35 | return; 36 | } 37 | 38 | var embed = Embeds.MakeBuilder(); 39 | embed.Title = $"{requestedLootTable.Name} loot table:"; 40 | embed.Description = Format.Italics(requestedLootTable.Description); 41 | 42 | if (requestedLootTable.ActivityType == ActivityType.Dungeon) 43 | embed.Description += 44 | $"\n\n{Format.Bold("Secret chests can drop any previously acquired armor and weapons.")}"; 45 | 46 | embed.ThumbnailUrl = requestedLootTable.ActivityType switch 47 | { 48 | ActivityType.Dungeon => BotVariables.Images.DungeonIcon, 49 | ActivityType.Raid => BotVariables.Images.RaidIcon, 50 | _ => string.Empty 51 | }; 52 | 53 | // ReSharper disable once ForeachCanBePartlyConvertedToQueryUsingAnotherGetEnumerator 54 | foreach (var table in requestedLootTable.Loot) 55 | { 56 | if (table.LootIds == null) 57 | continue; 58 | 59 | if (embed.Fields.Count is 2 or 5) 60 | embed.AddField("\u200b", '\u200b'); 61 | 62 | embed.AddField(table.EncounterName, BuildDrops(_bungieClient, table.LootIds), true); 63 | } 64 | 65 | await FollowupAsync(embed: embed.Build()); 66 | } 67 | 68 | private static string BuildDrops(IBungieClient bungieClient, List tableLootIds) 69 | { 70 | var result = string.Empty; 71 | 72 | foreach (var tableLootId in tableLootIds) 73 | { 74 | switch (tableLootId) 75 | { 76 | case (uint)Armor.Everything: 77 | result += "\n <:CS:996724235634491523> All Possible Drops"; 78 | continue; 79 | case (uint)Armor.Helmet: 80 | result += "<:helmet:996490149728899122> "; 81 | continue; 82 | case (uint)Armor.Gloves: 83 | result += "<:gloves:996490148025995385> "; 84 | continue; 85 | case (uint)Armor.Chest: 86 | result += "<:chest:996490146922901655> "; 87 | continue; 88 | case (uint)Armor.Boots: 89 | result += "<:boots:996490145224200292> "; 90 | continue; 91 | case (uint)Armor.Class: 92 | result += "<:class:996490144066572288> "; 93 | continue; 94 | } 95 | 96 | if (bungieClient.Repository.TryGetDestinyDefinition(tableLootId, 97 | out var manifestItem)) 98 | result += 99 | $"\n{EmoteHelper.GetItemType(manifestItem)} " + 100 | $"[{manifestItem.DisplayProperties.Name.Replace("(Timelost)", "(TL)")}]" + 101 | $"({MiscUtils.GetLightGgLink(tableLootId)})"; 102 | } 103 | 104 | return result; 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Felicity/Util/WeaponHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Web; 2 | using Discord.WebSocket; 3 | using DotNetBungieAPI.Models.Destiny; 4 | using DotNetBungieAPI.Models.Destiny.Components; 5 | using DotNetBungieAPI.Models.Destiny.Definitions.InventoryItems; 6 | using DotNetBungieAPI.Service.Abstractions; 7 | using Felicity.Models.Caches; 8 | 9 | // ReSharper disable InconsistentNaming 10 | // ReSharper disable UnusedMember.Global 11 | 12 | namespace Felicity.Util; 13 | 14 | internal static class WeaponHelper 15 | { 16 | public static string PopulateWeaponPerks( 17 | BaseSocketClient discordClient, 18 | List weapons, 19 | bool foundryLink) 20 | { 21 | var result = ""; 22 | 23 | foreach (var weapon in weapons) 24 | { 25 | if (weapon.DestinyItemType != null) result += EmoteHelper.GetItemType(weapon.DestinyItemType); 26 | 27 | if (weapon.Perks.Count == 0) 28 | { 29 | result += $"[{weapon.Name}]({MiscUtils.GetLightGgLink(weapon.WeaponId)}/)\n\n"; 30 | } 31 | else 32 | { 33 | if (foundryLink) 34 | result += $"[{weapon.Name}]({BuildFoundryLink(weapon.WeaponId, weapon.Perks)})\n"; 35 | else 36 | result += $"[{weapon.Name}]({MiscUtils.GetLightGgLink(weapon.WeaponId)}/) | "; 37 | 38 | foreach (var (_, value) in weapon.Perks) 39 | result += EmoteHelper.GetEmote(discordClient, value.IconPath!, value.Perkname!, value.PerkId); 40 | 41 | result += "\n"; 42 | } 43 | } 44 | 45 | return result; 46 | } 47 | 48 | public static Task> BuildPerks( 49 | IBungieClient bungieClient, 50 | ItemTierType inventoryTierType, 51 | DestinyItemSocketsComponent weaponPerk) 52 | { 53 | var response = new Dictionary(); 54 | 55 | // ReSharper disable once SwitchStatementMissingSomeEnumCasesNoDefault 56 | var goodPerkList = inventoryTierType switch 57 | { 58 | ItemTierType.Exotic => new[] { 1, 2, 3, 4 }, 59 | ItemTierType.Superior => new[] { 3, 4 }, 60 | _ => Array.Empty() 61 | }; 62 | 63 | if (goodPerkList.Length == 0) 64 | return Task.FromResult(response); 65 | 66 | var i = 0; 67 | 68 | foreach (var destinyItemSocketState in weaponPerk.Sockets) 69 | { 70 | if (goodPerkList.Contains(i)) 71 | { 72 | if (destinyItemSocketState.Plug.Hash == null) continue; 73 | if (!destinyItemSocketState.IsVisible) continue; 74 | 75 | response.Add(response.Count.ToString(), new Perk 76 | { 77 | PerkId = destinyItemSocketState.Plug.Hash 78 | }); 79 | } 80 | 81 | i++; 82 | } 83 | 84 | var fetchList = (from keyPair in response 85 | let valuePerkId = keyPair.Value.PerkId 86 | where valuePerkId != null 87 | select (uint)valuePerkId) 88 | .ToList(); 89 | 90 | foreach (var fetchPerk in fetchList) 91 | { 92 | bungieClient.Repository.TryGetDestinyDefinition(fetchPerk, 93 | out var manifestFetch); 94 | 95 | foreach (var perk in response.Where(perk => perk.Value.PerkId == manifestFetch.Hash)) 96 | { 97 | perk.Value.Perkname = manifestFetch.DisplayProperties.Name; 98 | perk.Value.IconPath = manifestFetch.DisplayProperties.Icon.RelativePath; 99 | } 100 | } 101 | 102 | return Task.FromResult(response); 103 | } 104 | 105 | public static string BuildLightGGLink(string armorLegendarySet) 106 | { 107 | var search = armorLegendarySet.ToLower().Replace("suit", "") 108 | .Replace("set", "").Replace("armor", ""); 109 | return $"https://www.light.gg/db/all?page=1&f=12({HttpUtility.UrlEncode(search.TrimEnd(' '))}),3"; 110 | } 111 | 112 | private static string BuildFoundryLink( 113 | uint exoticWeaponWeaponId, 114 | Dictionary exoticWeaponPerks) 115 | { 116 | var result = $"https://d2foundry.gg/w/{exoticWeaponWeaponId}?p="; 117 | 118 | result = exoticWeaponPerks.Values.Aggregate(result, (current, value) => current + (value.PerkId + ",")); 119 | 120 | return result.TrimEnd(','); 121 | } 122 | 123 | public static int TotalStats(Stats exoticArmorStats) 124 | { 125 | return exoticArmorStats.Mobility + exoticArmorStats.Resilience + exoticArmorStats.Recovery + 126 | exoticArmorStats.Discipline + exoticArmorStats.Intellect + exoticArmorStats.Strength; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | We as members, contributors, and leaders pledge to make participation in our 6 | community a harassment-free experience for everyone, regardless of age, body 7 | size, visible or invisible disability, ethnicity, sex characteristics, gender 8 | identity and expression, level of experience, education, socio-economic status, 9 | nationality, personal appearance, race, religion, or sexual identity 10 | and orientation. 11 | 12 | We pledge to act and interact in ways that contribute to an open, welcoming, 13 | diverse, inclusive, and healthy community. 14 | 15 | ## Our Standards 16 | 17 | Examples of behavior that contributes to a positive environment for our 18 | community include: 19 | 20 | * Demonstrating empathy and kindness toward other people 21 | * Being respectful of differing opinions, viewpoints, and experiences 22 | * Giving and gracefully accepting constructive feedback 23 | * Accepting responsibility and apologizing to those affected by our mistakes, 24 | and learning from the experience 25 | * Focusing on what is best not just for us as individuals, but for the 26 | overall community 27 | 28 | Examples of unacceptable behavior include: 29 | 30 | * The use of sexualized language or imagery, and sexual attention or 31 | advances of any kind 32 | * Trolling, insulting or derogatory comments, and personal or political attacks 33 | * Public or private harassment 34 | * Publishing others' private information, such as a physical or email 35 | address, without their explicit permission 36 | * Other conduct which could reasonably be considered inappropriate in a 37 | professional setting 38 | 39 | ## Enforcement Responsibilities 40 | 41 | Community leaders are responsible for clarifying and enforcing our standards of 42 | acceptable behavior and will take appropriate and fair corrective action in 43 | response to any behavior that they deem inappropriate, threatening, offensive, 44 | or harmful. 45 | 46 | Community leaders have the right and responsibility to remove, edit, or reject 47 | comments, commits, code, wiki edits, issues, and other contributions that are 48 | not aligned to this Code of Conduct, and will communicate reasons for moderation 49 | decisions when appropriate. 50 | 51 | ## Scope 52 | 53 | This Code of Conduct applies within all community spaces, and also applies when 54 | an individual is officially representing the community in public spaces. 55 | Examples of representing our community include using an official e-mail address, 56 | posting via an official social media account, or acting as an appointed 57 | representative at an online or offline event. 58 | 59 | ## Enforcement 60 | 61 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 62 | reported to the community leaders responsible for enforcement at 63 | contact@whaskell.pw. 64 | All complaints will be reviewed and investigated promptly and fairly. 65 | 66 | All community leaders are obligated to respect the privacy and security of the 67 | reporter of any incident. 68 | 69 | ## Enforcement Guidelines 70 | 71 | Community leaders will follow these Community Impact Guidelines in determining 72 | the consequences for any action they deem in violation of this Code of Conduct: 73 | 74 | ### 1. Correction 75 | 76 | **Community Impact**: Use of inappropriate language or other behavior deemed 77 | unprofessional or unwelcome in the community. 78 | 79 | **Consequence**: A private, written warning from community leaders, providing 80 | clarity around the nature of the violation and an explanation of why the 81 | behavior was inappropriate. A public apology may be requested. 82 | 83 | ### 2. Warning 84 | 85 | **Community Impact**: A violation through a single incident or series 86 | of actions. 87 | 88 | **Consequence**: A warning with consequences for continued behavior. No 89 | interaction with the people involved, including unsolicited interaction with 90 | those enforcing the Code of Conduct, for a specified period of time. This 91 | includes avoiding interactions in community spaces as well as external channels 92 | like social media. Violating these terms may lead to a temporary or 93 | permanent ban. 94 | 95 | ### 3. Temporary Ban 96 | 97 | **Community Impact**: A serious violation of community standards, including 98 | sustained inappropriate behavior. 99 | 100 | **Consequence**: A temporary ban from any sort of interaction or public 101 | communication with the community for a specified period of time. No public or 102 | private interaction with the people involved, including unsolicited interaction 103 | with those enforcing the Code of Conduct, is allowed during this period. 104 | Violating these terms may lead to a permanent ban. 105 | 106 | ### 4. Permanent Ban 107 | 108 | **Community Impact**: Demonstrating a pattern of violation of community 109 | standards, including sustained inappropriate behavior, harassment of an 110 | individual, or aggression toward or disparagement of classes of individuals. 111 | 112 | **Consequence**: A permanent ban from any sort of public interaction within 113 | the community. 114 | 115 | ## Attribution 116 | 117 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], 118 | version 2.0, available at 119 | https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. 120 | 121 | Community Impact Guidelines were inspired by [Mozilla's code of conduct 122 | enforcement ladder](https://github.com/mozilla/diversity). 123 | 124 | [homepage]: https://www.contributor-covenant.org 125 | 126 | For answers to common questions about this code of conduct, see the FAQ at 127 | https://www.contributor-covenant.org/faq. Translations are available at 128 | https://www.contributor-covenant.org/translations. 129 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/MementoCommands.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.Interactions; 3 | using Felicity.Models.Caches; 4 | using Felicity.Util; 5 | 6 | // ReSharper disable UnusedMember.Global 7 | // ReSharper disable UnusedType.Global 8 | 9 | namespace Felicity.DiscordCommands.Interactions; 10 | 11 | public class MementoCommands : InteractionModuleBase 12 | { 13 | [SlashCommand("memento", "Curious to see how a memento will look?")] 14 | public async Task Memento( 15 | [Summary("source", "Where does the weapon you want to check out come from?")] 16 | MementoSource memSource, 17 | [Autocomplete(typeof(MementoWeaponAutocomplete))] [Summary("weapon", "Name of the weapon you want to see.")] 18 | string memWeapon, 19 | [Summary("type", "What type of memento are you looking for?")] 20 | MementoType memType 21 | ) 22 | { 23 | await DeferAsync(); 24 | 25 | var memCache = await ProcessMementoData.ReadJsonAsync(); 26 | if (memCache == null) 27 | { 28 | await FollowupAsync("Error fetching memento cache."); 29 | return; 30 | } 31 | 32 | MementoCache.MementoWeaponList goodWeapon = null!; 33 | 34 | foreach (var mementoInventoryElement in memCache.MementoInventory!) 35 | foreach (var weapon in mementoInventoryElement.WeaponList!) 36 | if (weapon.WeaponName == memWeapon) 37 | goodWeapon = weapon; 38 | 39 | if (goodWeapon == null!) 40 | { 41 | await FollowupAsync("An error occurred while fetching memento, try filling arguments in order."); 42 | return; 43 | } 44 | 45 | var goodMemento = goodWeapon.TypeList?.FirstOrDefault(x => x.Type == memType)?.Memento; 46 | 47 | if (goodMemento?.Credit == "NoImage") 48 | { 49 | var errorEmbed = new EmbedBuilder 50 | { 51 | Author = new EmbedAuthorBuilder 52 | { 53 | Name = "TheLastJoaquin", 54 | IconUrl = BotVariables.Images.JoaquinAvatar, 55 | Url = "https://twitter.com/TheLastJoaquin" 56 | }, 57 | Color = Color.Red, 58 | Description = 59 | $"Sorry! We don't currently have the Memento image for **{goodWeapon.WeaponName}** ({memType}) :(\n\n" + 60 | $"If you have it and would like to submit it, please head to our [Support Server]({BotVariables.DiscordInvite}) and send it to us there!" 61 | }; 62 | 63 | await FollowupAsync(embed: errorEmbed.Build()); 64 | return; 65 | } 66 | 67 | var embedColor = memType switch 68 | { 69 | MementoType.Gambit => Color.Green, 70 | MementoType.Nightfall => Color.Orange, 71 | MementoType.Trials => Color.Gold, 72 | _ => Color.Blue 73 | }; 74 | 75 | var embed = new EmbedBuilder 76 | { 77 | Author = new EmbedAuthorBuilder 78 | { 79 | Name = "TheLastJoaquin", 80 | IconUrl = BotVariables.Images.JoaquinAvatar, 81 | Url = "https://twitter.com/TheLastJoaquin" 82 | }, 83 | Title = "Memento Preview:", 84 | Description = 85 | $"This is what **{goodWeapon.WeaponName}** looks like with a **{memType}** Memento equipped.", 86 | Color = embedColor, 87 | Fields = new List 88 | { 89 | new() { IsInline = true, Name = "Source", Value = GetMementoSourceString(memSource) }, 90 | new() { IsInline = true, Name = "Credit", Value = GetCredit(goodMemento?.Credit) } 91 | }, 92 | ImageUrl = goodMemento?.ImageUrl, 93 | Footer = Embeds.MakeFooter(), 94 | ThumbnailUrl = GetMementoImage(memType) 95 | }; 96 | 97 | await FollowupAsync(embed: embed.Build()); 98 | } 99 | 100 | private static string GetCredit(string? goodMementoCredit) 101 | { 102 | if (goodMementoCredit is null or "Unknown") 103 | return "Unknown"; 104 | 105 | if (goodMementoCredit.StartsWith("/u/")) 106 | return $"[{goodMementoCredit}](https://reddit.com{goodMementoCredit})"; 107 | 108 | if (goodMementoCredit.StartsWith("@")) 109 | return $"[{goodMementoCredit}](https://twitter.com/{goodMementoCredit})"; 110 | 111 | // ReSharper disable once ConvertIfStatementToReturnStatement 112 | if (goodMementoCredit.Equals("yt/Benny Clips")) 113 | return $"[{goodMementoCredit}](https://www.youtube.com/channel/UCfxAFtfQnN0fpUY6WD_Z5wQ)"; 114 | 115 | return goodMementoCredit; 116 | } 117 | 118 | private static string GetMementoImage(MementoType memType) 119 | { 120 | return memType switch 121 | { 122 | MementoType.Gambit => 123 | "https://bungie.net/common/destiny2_content/icons/045e66a538f70024c194b01a5cf8652a.jpg", 124 | MementoType.Trials => 125 | "https://bungie.net/common/destiny2_content/icons/c2e0148851bd8aec5d04d413b897dcbd.jpg", 126 | MementoType.Nightfall => 127 | "https://bungie.net/common/destiny2_content/icons/bf21c13f03a29aa0067f85c84593a594.jpg", 128 | _ => "" 129 | }; 130 | } 131 | 132 | private static string GetMementoSourceString(MementoSource memSource) 133 | { 134 | var goodSource = memSource switch 135 | { 136 | MementoSource.OpenWorld => "Open World", 137 | MementoSource.RaidVotD => "Vow of the Disciple", 138 | MementoSource.SeasonRisen => "Seasonal (Risen)", 139 | MementoSource.SeasonHaunted => "Seasonal (Haunted)", 140 | MementoSource.ThroneWorld => "Throne World", 141 | _ => "Unknown" 142 | }; 143 | 144 | return goodSource; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/RollFinderCommands.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using Discord; 3 | using Discord.Interactions; 4 | using DotNetBungieAPI.Models.Destiny; 5 | using DotNetBungieAPI.Models.Destiny.Definitions.InventoryItems; 6 | using DotNetBungieAPI.Service.Abstractions; 7 | using Felicity.Models; 8 | using Felicity.Util; 9 | using Humanizer; 10 | 11 | // ReSharper disable UnusedMember.Global 12 | // ReSharper disable UnusedType.Global 13 | 14 | namespace Felicity.DiscordCommands.Interactions; 15 | 16 | public class RollFinderCommands : InteractionModuleBase 17 | { 18 | private readonly IBungieClient _bungieClient; 19 | 20 | public RollFinderCommands(IBungieClient bungieClient) 21 | { 22 | _bungieClient = bungieClient; 23 | } 24 | 25 | [SlashCommand("roll-finder", "Find a recommended roll for a specific weapon from a curated list.")] 26 | public async Task RollFinder( 27 | [Summary("game-mode", "Game mode to suggest rolls for:")] [Choice("PvE", 0)] [Choice("PvP", 1)] 28 | int gameMode, 29 | [Autocomplete(typeof(RollFinderAutocomplete))] [Summary("weapon-name", "Name of the weapon to search for:")] 30 | uint weaponId) 31 | { 32 | await DeferAsync(); 33 | 34 | var weaponRollList = await ProcessRollData.FromJsonAsync(); 35 | 36 | if (weaponRollList == null) 37 | { 38 | await FollowupAsync("An error occurred fetching curated weapon rolls."); 39 | return; 40 | } 41 | 42 | var rollList = gameMode switch 43 | { 44 | 0 => weaponRollList.PvE, 45 | 1 => weaponRollList.PvP, 46 | _ => null 47 | }; 48 | 49 | if (rollList == null) 50 | { 51 | await FollowupAsync("An error has occurred."); 52 | return; 53 | } 54 | 55 | var requestedRoll = rollList.Where(x => x.WeaponId == weaponId).ToList(); 56 | if (!requestedRoll.Any()) 57 | { 58 | await FollowupAsync("An error has occurred while fetching requested roll."); 59 | return; 60 | } 61 | 62 | var embed = Embeds.MakeBuilder(); 63 | 64 | if (weaponRollList.Authors != null) 65 | { 66 | var rollAuthor = weaponRollList.Authors.FirstOrDefault(x => x.Id == requestedRoll.First().AuthorId); 67 | embed.Author = new EmbedAuthorBuilder 68 | { 69 | Name = rollAuthor?.Name, 70 | IconUrl = rollAuthor?.Image, 71 | Url = rollAuthor?.Url 72 | }; 73 | } 74 | 75 | if (!_bungieClient.Repository.TryGetDestinyDefinition( 76 | requestedRoll.First().WeaponId, 77 | out var weaponDefinition)) 78 | { 79 | await FollowupAsync("Failed to fetch weapon from manifest."); 80 | return; 81 | } 82 | 83 | embed.ThumbnailUrl = weaponDefinition.DisplayProperties.Icon.AbsolutePath; 84 | 85 | var perks = string.Empty; 86 | 87 | for (var i = 0; i < requestedRoll.First().WeaponPerks.Count - 1; i++) 88 | perks += $"{requestedRoll.First().WeaponPerks[i]},"; 89 | 90 | var foundryLink = 91 | $"https://d2foundry.gg/w/{requestedRoll.First().WeaponId}?p={perks.TrimEnd(',')}&m=0&mw={requestedRoll.First().WeaponPerks.Last()}"; 92 | 93 | embed.Description = 94 | $"This is the recommended {Format.Bold(gameMode == 0 ? "PvE" : "PvP")} roll for {Format.Bold(weaponDefinition.DisplayProperties.Name)}.\n" + 95 | $"[Click here]({MiscUtils.GetLightGgLink(requestedRoll.First().WeaponId)}) to view the weapon on Light.GG."; 96 | 97 | embed.AddField("Type", EmoteHelper.StaticEmote(weaponDefinition.EquippingBlock.AmmoType.ToString()) + 98 | EmoteHelper.StaticEmote(weaponDefinition.DefaultDamageTypeEnumValue.ToString()) + 99 | EmoteHelper.GetItemType(weaponDefinition.ItemSubType), true); 100 | embed.AddField("Acquirable", requestedRoll.First().CanDrop, true); 101 | embed.AddField("Source", ReadSource(requestedRoll.First().Source), true); 102 | 103 | for (var i = 0; i < requestedRoll.Count; i++) 104 | { 105 | embed.AddField(requestedRoll.Count != 1 ? $"Recommended roll {i + 1}" : "Why should you pick this roll?", 106 | $"> {requestedRoll[i].Reason}", true); 107 | embed.AddField("Perks", GetPerkList(requestedRoll[i]), true); 108 | embed.AddField("Foundry Link", $"[Click Here]({foundryLink})", true); 109 | } 110 | 111 | await FollowupAsync(embed: embed.Build()); 112 | } 113 | 114 | private static string ReadSource(WeaponSource source) 115 | { 116 | // ReSharper disable once SwitchStatementHandlesSomeKnownEnumValuesWithDefault 117 | switch (source) 118 | { 119 | case WeaponSource.XurEternity: 120 | return "Dares Of Eternity"; 121 | default: 122 | return source.Humanize(LetterCasing.Title); 123 | } 124 | } 125 | 126 | private string GetPerkList(Roll requestedRoll) 127 | { 128 | var perkList = new StringBuilder(); 129 | 130 | foreach (var weaponPerk in requestedRoll.WeaponPerks) 131 | { 132 | if (weaponPerk == 0) 133 | { 134 | perkList.Append("*no data*\n"); 135 | continue; 136 | } 137 | 138 | _bungieClient.Repository.TryGetDestinyDefinition(weaponPerk, 139 | out var weaponPerkDefinition); 140 | 141 | if (weaponPerkDefinition.Plug.PlugStyle == PlugUiStyles.Masterwork) 142 | perkList.Append( 143 | EmoteHelper.StaticEmote(weaponPerkDefinition.Plug.PlugCategoryIdentifier.Split('.').Last())); 144 | else 145 | perkList.Append(EmoteHelper.GetEmote(Context.Client, 146 | weaponPerkDefinition.DisplayProperties.Icon.RelativePath, 147 | weaponPerkDefinition.DisplayProperties.Name, weaponPerkDefinition.Hash)); 148 | 149 | perkList.Append(weaponPerkDefinition.DisplayProperties.Name); 150 | 151 | perkList.Append('\n'); 152 | } 153 | 154 | return perkList.ToString(); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/NewsCommands.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using Discord; 3 | using Discord.Interactions; 4 | using DotNetBungieAPI.Models.Content; 5 | using DotNetBungieAPI.Service.Abstractions; 6 | using Felicity.Util; 7 | 8 | // ReSharper disable UnusedType.Global 9 | // ReSharper disable UnusedMember.Global 10 | 11 | namespace Felicity.DiscordCommands.Interactions; 12 | 13 | public class NewsCommands : InteractionModuleBase 14 | { 15 | private readonly IBungieClient _bungieClient; 16 | 17 | public NewsCommands(IBungieClient bungieClient) 18 | { 19 | _bungieClient = bungieClient; 20 | } 21 | 22 | [SlashCommand("twab", "Where TWAB?")] 23 | public async Task Twab( 24 | [Summary("query", "Search TWABs for a phrase or word. If null, latest TWAB will be returned.")] 25 | string search = 26 | "") 27 | { 28 | await DeferAsync(); 29 | 30 | if (!await BungieApiUtils.CheckApi(_bungieClient)) 31 | throw new Exception("Bungie API is down or unresponsive."); 32 | 33 | const string lg = "en"; 34 | 35 | if (!string.IsNullOrEmpty(search)) 36 | { 37 | var searchEmbed = new EmbedBuilder 38 | { 39 | Title = $"TWAB search for `{search}`.", 40 | Color = Color.Teal, 41 | Footer = Embeds.MakeFooter() 42 | }; 43 | 44 | var results = await SearchTwab(search); 45 | 46 | if (results.Count != 0) 47 | searchEmbed.Fields = results; 48 | 49 | await FollowupAsync(embed: searchEmbed.Build()); 50 | 51 | return; 52 | } 53 | 54 | var twabTask = 55 | await _bungieClient.ApiAccess.Content.SearchContentWithText(lg, new[] { "news" }, "this week at bungie", "", 56 | ""); 57 | 58 | var twab = twabTask.Response.Results.FirstOrDefault(); 59 | 60 | if (twab == null) 61 | { 62 | var errorEmbed = new EmbedBuilder 63 | { 64 | Description = "Failed to find latest TWAB.", 65 | Color = Color.Teal, 66 | Footer = Embeds.MakeFooter() 67 | }; 68 | 69 | await FollowupAsync(embed: errorEmbed.Build()); 70 | return; 71 | } 72 | 73 | var url = $"{BotVariables.BungieBaseUrl}{lg}/Explore/Detail/News/{twab.ContentId}"; 74 | 75 | var embed = new EmbedBuilder 76 | { 77 | Author = new EmbedAuthorBuilder 78 | { 79 | Name = twab.Author.DisplayName, 80 | IconUrl = $"{BotVariables.BungieBaseUrl}{twab.Author.ProfilePicturePath}" 81 | }, 82 | Color = Color.Teal, 83 | Description = twab.Properties["Subtitle"].ToString(), 84 | Fields = new List 85 | { 86 | new() 87 | { 88 | Name = "Created:", 89 | IsInline = true, 90 | Value = $"" 91 | }, 92 | new() 93 | { 94 | Name = "Modified:", 95 | IsInline = true, 96 | Value = $"" 97 | } 98 | }, 99 | Footer = Embeds.MakeFooter(), 100 | ImageUrl = $"{BotVariables.BungieBaseUrl}{twab.Properties["ArticleBanner"]}", 101 | Title = twab.Properties["Title"].ToString(), 102 | Url = url 103 | }; 104 | 105 | await FollowupAsync(embed: embed.Build()); 106 | } 107 | 108 | private async Task> SearchTwab(string search) 109 | { 110 | var twabList = new List(); 111 | 112 | twabList.AddRange(await FillTwabList("this week at bungie")); 113 | twabList.AddRange(await FillTwabList("bungie weekly update")); 114 | 115 | var foundTwabs = twabList.Where(twabLink => 116 | twabLink.Properties["Content"].ToString()!.ToLower().Contains(search.ToLower())); 117 | 118 | var sb = new StringBuilder(); 119 | var counter = 0; 120 | 121 | var embedFields = new List(); 122 | 123 | foreach (var result in foundTwabs) 124 | { 125 | counter++; 126 | var currentLength = sb.Length; 127 | 128 | var title = string.IsNullOrEmpty(result.Properties["Subtitle"].ToString()) 129 | ? result.Properties["Title"].ToString() 130 | : result.Properties["Subtitle"].ToString(); 131 | 132 | var newResult = 133 | $"> {counter}. [{title}]({BotVariables.BungieBaseUrl}en/Explore/Detail/News/{result.ContentId})"; 134 | 135 | if (currentLength + newResult.Length >= 1024) 136 | { 137 | embedFields.Add(new EmbedFieldBuilder 138 | { 139 | IsInline = true, 140 | Name = "Results", 141 | Value = sb.ToString() 142 | }); 143 | sb.Clear(); 144 | } 145 | 146 | sb.AppendLine(newResult); 147 | } 148 | 149 | if (sb.Length > 0) 150 | embedFields.Add(new EmbedFieldBuilder 151 | { 152 | IsInline = true, 153 | Name = "Results", 154 | Value = sb.ToString() 155 | }); 156 | 157 | return embedFields; 158 | } 159 | 160 | private async Task> FillTwabList(string query) 161 | { 162 | var twabList = new List(); 163 | 164 | var done = false; 165 | var i = 1; 166 | 167 | do 168 | { 169 | var twabPage = 170 | await _bungieClient.ApiAccess.Content.SearchContentWithText("en", new[] { "news" }, query, "", "", i); 171 | 172 | foreach (var page in twabPage.Response.Results) 173 | { 174 | var alreadyPresent = false; 175 | 176 | foreach (var unused in twabList.Where(contentItemPublicContract => 177 | contentItemPublicContract.ContentId == page.ContentId)) 178 | alreadyPresent = true; 179 | 180 | if (alreadyPresent) continue; 181 | 182 | if (!page.Properties["Title"].ToString()!.ToLower().Contains("this week")) 183 | continue; 184 | 185 | if (!twabList.Contains(page)) 186 | twabList.Add(page); 187 | } 188 | 189 | if (twabPage.Response.HasMore) 190 | { 191 | i++; 192 | continue; 193 | } 194 | 195 | done = true; 196 | } while (!done); 197 | 198 | return twabList; 199 | } 200 | } 201 | -------------------------------------------------------------------------------- /Felicity/Models/Caches/ModCache.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using System.Text.Json; 3 | using Discord; 4 | using DotNetBungieAPI.HashReferences; 5 | using DotNetBungieAPI.Models.Destiny; 6 | using DotNetBungieAPI.Models.Destiny.Components; 7 | using DotNetBungieAPI.Models.Destiny.Definitions.InventoryItems; 8 | using DotNetBungieAPI.Models.Destiny.Definitions.SandboxPerks; 9 | using DotNetBungieAPI.Service.Abstractions; 10 | using Felicity.Util; 11 | 12 | // ReSharper disable PropertyCanBeMadeInitOnly.Global 13 | // ReSharper disable UnusedType.Global 14 | // ReSharper disable UnusedMember.Global 15 | 16 | namespace Felicity.Models.Caches; 17 | 18 | public class ModCache 19 | { 20 | public Dictionary>? ModInventory { get; set; } 21 | public DateTime InventoryExpires { get; set; } 22 | } 23 | 24 | public class Mod 25 | { 26 | public uint Id { get; set; } 27 | public string? Name { get; set; } 28 | public string? Description { get; set; } 29 | } 30 | 31 | public static class ProcessModData 32 | { 33 | public static async Task BuildEmbed(IBungieClient bungieClient, ModCache self, User user) 34 | { 35 | var embed = Embeds.MakeBuilder(); 36 | 37 | embed.Author = new EmbedAuthorBuilder 38 | { 39 | Name = "Mod Vendors:", 40 | IconUrl = BotVariables.Images.ModVendorIcon 41 | }; 42 | 43 | embed.Description = "Ada-1 and Banshee-44 can both be found in the Tower."; 44 | 45 | var adaMods = await GetMods(bungieClient, self, DefinitionHashes.Vendors.Ada1_350061650, user); 46 | var bansheeMods = await GetMods(bungieClient, self, DefinitionHashes.Vendors.Banshee44_672118013, user); 47 | 48 | embed.AddField("Ada-1", adaMods, true); 49 | embed.AddField("Banshee-44", bansheeMods, true); 50 | 51 | if (adaMods.Contains("⚠️") || bansheeMods.Contains("⚠️")) 52 | embed.Description += "\n\n⚠️ - not owned in collections."; 53 | 54 | return embed.Build(); 55 | } 56 | 57 | private static async Task GetMods(IBungieClient bungieClient, ModCache self, uint vendor, User user) 58 | { 59 | var clarityDb = await ClarityParser.Fetch(); 60 | 61 | var result = new StringBuilder(); 62 | 63 | var characterIdTask = await bungieClient.ApiAccess.Destiny2.GetProfile(user.DestinyMembershipType, 64 | user.DestinyMembershipId, new[] 65 | { 66 | DestinyComponentType.Characters 67 | }); 68 | 69 | var vendorData = await bungieClient.ApiAccess.Destiny2.GetVendors(user.DestinyMembershipType, 70 | user.DestinyMembershipId, characterIdTask.Response.Characters.Data.Keys.First(), new[] 71 | { 72 | DestinyComponentType.VendorSales 73 | }, user.GetTokenData()); 74 | 75 | foreach (var mod in self.ModInventory![vendor.ToString()]) 76 | { 77 | var missing = ""; 78 | 79 | foreach (var personalDestinyVendorSaleItemSetComponent in vendorData.Response.Sales.Data) 80 | foreach (var destinyVendorSaleItemComponent in personalDestinyVendorSaleItemSetComponent.Value.SaleItems) 81 | if (destinyVendorSaleItemComponent.Value.Item.Hash == mod.Id) 82 | if (destinyVendorSaleItemComponent.Value.SaleStatus == VendorItemStatus.Success) 83 | missing = "⚠️ "; 84 | 85 | Clarity? clarityValue = null; 86 | 87 | if (clarityDb != null && clarityDb.ContainsKey(mod.Id.ToString())) 88 | clarityValue = clarityDb[mod.Id.ToString()]; 89 | 90 | result.Append($"{missing}[{mod.Name}]({MiscUtils.GetLightGgLink(mod.Id)})\n" + 91 | $"> {Format.Italics(mod.Description)}\n"); 92 | 93 | if (clarityValue != null) 94 | result.Append($"> [Clarity](https://www.d2clarity.com/): {clarityValue.Description}\n"); 95 | 96 | result.Append('\n'); 97 | } 98 | 99 | return result.ToString(); 100 | } 101 | 102 | public static async Task FetchInventory(IBungieClient bungieClient, User oauth) 103 | { 104 | ModCache modCache; 105 | 106 | const string path = "Data/modCache.json"; 107 | 108 | if (File.Exists(path)) 109 | { 110 | modCache = JsonSerializer.Deserialize(await File.ReadAllTextAsync(path))!; 111 | 112 | if (modCache.InventoryExpires < DateTime.UtcNow) 113 | File.Delete(path); 114 | else 115 | return modCache; 116 | } 117 | 118 | var characterIdTask = await bungieClient.ApiAccess.Destiny2.GetProfile(oauth.DestinyMembershipType, 119 | oauth.DestinyMembershipId, new[] 120 | { 121 | DestinyComponentType.Characters 122 | }); 123 | 124 | var vendorData = await bungieClient.ApiAccess.Destiny2.GetVendors(oauth.DestinyMembershipType, 125 | oauth.DestinyMembershipId, characterIdTask.Response.Characters.Data.Keys.First(), new[] 126 | { 127 | DestinyComponentType.VendorSales 128 | }, oauth.GetTokenData()); 129 | 130 | modCache = new ModCache 131 | { 132 | InventoryExpires = ResetUtils.GetNextDailyReset(), 133 | ModInventory = new Dictionary>() 134 | }; 135 | 136 | modCache = await PopulateMods(bungieClient, modCache, DefinitionHashes.Vendors.Ada1_350061650, 137 | vendorData.Response.Sales.Data); 138 | modCache = await PopulateMods(bungieClient, modCache, DefinitionHashes.Vendors.Banshee44_672118013, 139 | vendorData.Response.Sales.Data); 140 | 141 | await File.WriteAllTextAsync(path, JsonSerializer.Serialize(modCache)); 142 | 143 | return modCache; 144 | } 145 | 146 | private static Task PopulateMods(IBungieClient bungieClient, ModCache modCache, 147 | uint vendor, 148 | IReadOnlyDictionary salesData) 149 | { 150 | modCache.ModInventory?.Add(vendor.ToString(), new List()); 151 | 152 | foreach (var saleItemsValue in salesData[vendor].SaleItems.Values) 153 | { 154 | if (saleItemsValue.Item.Hash == null) 155 | continue; 156 | 157 | bungieClient.Repository.TryGetDestinyDefinition( 158 | (uint)saleItemsValue.Item.Hash, out var manifestItem); 159 | 160 | if (manifestItem.ItemType != DestinyItemType.Mod) 161 | continue; 162 | 163 | bungieClient.Repository.TryGetDestinyDefinition( 164 | (uint)manifestItem.Perks.First().Perk.Hash!, out var result); 165 | 166 | modCache.ModInventory![vendor.ToString()].Add(new Mod 167 | { 168 | Name = result.DisplayProperties.Name, 169 | Description = result.DisplayProperties.Description, 170 | Id = (uint)saleItemsValue.Item.Hash! 171 | }); 172 | } 173 | 174 | return Task.FromResult(modCache); 175 | } 176 | } 177 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/ServerCommands.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.Interactions; 3 | using Discord.WebSocket; 4 | using Felicity.Models; 5 | using Felicity.Util; 6 | 7 | // ReSharper disable StringLiteralTypo 8 | // ReSharper disable UnusedMember.Global 9 | // ReSharper disable UnusedType.Global 10 | 11 | namespace Felicity.DiscordCommands.Interactions; 12 | 13 | [RequireContext(ContextType.Guild)] 14 | [DefaultMemberPermissions(GuildPermission.ManageGuild)] 15 | [Group("server", "Collection of server management commands for setting up your server.")] 16 | public class ServerCommands : InteractionModuleBase 17 | { 18 | private readonly ServerDb _serverDb; 19 | 20 | public ServerCommands(ServerDb serverDb) 21 | { 22 | _serverDb = serverDb; 23 | } 24 | 25 | [SlashCommand("configure", "Set up your server.")] 26 | public async Task ServerConfigure( 27 | [Summary("announcements", "Which channel should Felicity send announcements related to bot services?")] 28 | ITextChannel announcementChannel, 29 | [Summary("staff", "Which channel should Felicity send STAFF-ONLY announcements to?")] 30 | ITextChannel staffChannel, 31 | [Summary("memberchannel", "Which channel do I send to?")] 32 | ITextChannel? memberLogChannel = null, 33 | [Summary("memberjoined", "Should I send messages when a member joins?")] 34 | bool memberJoined = false, 35 | [Summary("memberleft", "Should I send messages when a member leaves?")] 36 | bool memberLeft = false) 37 | { 38 | await DeferAsync(true); 39 | 40 | var server = MiscUtils.GetServer(_serverDb, Context.Guild.Id); 41 | server.AnnouncementChannel = announcementChannel.Id; 42 | server.StaffChannel = staffChannel.Id; 43 | 44 | if (memberLogChannel != null) 45 | server.MemberLogChannel = memberLogChannel.Id; 46 | 47 | server.MemberJoined = memberJoined; 48 | server.MemberLeft = memberLeft; 49 | 50 | await _serverDb.SaveChangesAsync(); 51 | 52 | var embed = Embeds.MakeBuilder(); 53 | embed.Author = new EmbedAuthorBuilder 54 | { 55 | Name = Context.Guild.Name, 56 | IconUrl = Context.Guild.IconUrl 57 | }; 58 | embed.Description = "Summary of server settings:"; 59 | 60 | var channelSummary = $"Announcements: {announcementChannel.Mention}\nStaff: {staffChannel.Mention}"; 61 | if (memberLogChannel != null) 62 | channelSummary += $"\nJoin/Leave: {memberLogChannel.Mention}"; 63 | 64 | embed.AddField("Channels", channelSummary, true); 65 | embed.AddField("Language", server.BungieLocale, true); 66 | embed.AddField("Member Events", $"Joins: {memberJoined}\nLeaves: {memberLeft}", true); 67 | 68 | await FollowupAsync(embed: embed.Build()); 69 | } 70 | 71 | [SlashCommand("summary", "Get a summary of current server settings.")] 72 | public async Task ServerSummary() 73 | { 74 | await DeferAsync(true); 75 | 76 | var server = MiscUtils.GetServer(_serverDb, Context.Guild.Id); 77 | 78 | var embed = Embeds.MakeBuilder(); 79 | embed.Author = new EmbedAuthorBuilder 80 | { 81 | Name = Context.Guild.Name, 82 | IconUrl = Context.Guild.IconUrl 83 | }; 84 | embed.Description = "Summary of server settings:"; 85 | 86 | var channelSummary = $"Announcements: {GetChannel(Context.Guild, server.AnnouncementChannel)}\n" + 87 | $"Staff: {GetChannel(Context.Guild, server.StaffChannel)}"; 88 | if (server.MemberLogChannel != null) 89 | channelSummary += $"\nJoin/Leave: {GetChannel(Context.Guild, server.MemberLogChannel)}"; 90 | 91 | embed.AddField("Channels", channelSummary, true); 92 | embed.AddField("Language", server.BungieLocale, true); 93 | embed.AddField("Member Events", $"Joins: {server.MemberJoined}\nLeaves: {server.MemberLeft}", true); 94 | 95 | await FollowupAsync(embed: embed.Build()); 96 | } 97 | 98 | private static string GetChannel(SocketGuild guild, ulong? channelId) 99 | { 100 | return channelId == null ? "not set." : guild.GetTextChannel((ulong)channelId).Mention; 101 | } 102 | 103 | /*[RequireContext(ContextType.Guild)] 104 | [Preconditions.RequireBotModerator] 105 | [Group("twitch", "Manage Twitch stream notifications for this server.")] 106 | public class TwitchNotifications : InteractionModuleBase 107 | { 108 | private readonly TwitchStreamDb _streamDb; 109 | private readonly TwitchService _twitchClientService; 110 | 111 | public TwitchNotifications(TwitchStreamDb streamDb, TwitchService twitchClientService) 112 | { 113 | _streamDb = streamDb; 114 | _twitchClientService = twitchClientService; 115 | } 116 | 117 | [SlashCommand("add", "Add a Twitch stream to the server.")] 118 | public async Task ServerTwitchAdd( 119 | [Summary("twitchname", "Stream name you'd like to subscribe to.")] 120 | string twitchName, 121 | [Summary("channel", "Channel you'd like me to post notifications to.")] 122 | ITextChannel channel, 123 | [Summary("everyone", "Should I ping everyone when they go live?")] 124 | bool mentionEveryone = false, 125 | [Summary("role", "Should I ping a role when they go live?")] 126 | IRole? role = null, 127 | [Summary("discordname", "If the streamer is in your server, I can mention them.")] 128 | IGuildUser? user = null) 129 | { 130 | await DeferAsync(); 131 | 132 | var stream = new TwitchStream 133 | { 134 | TwitchName = twitchName.ToLower(), 135 | ServerId = Context.Guild.Id, 136 | ChannelId = channel.Id, 137 | MentionEveryone = mentionEveryone 138 | }; 139 | 140 | if (role != null) stream.MentionRole = role.Id; 141 | if (user != null) stream.UserId = user.Id; 142 | 143 | _streamDb.TwitchStreams.Add(stream); 144 | await _streamDb.SaveChangesAsync(); 145 | 146 | _twitchClientService.RestartMonitor(); 147 | 148 | await FollowupAsync($"Added {Format.Bold(twitchName)}'s stream to {channel.Mention}", ephemeral: true); 149 | } 150 | 151 | [SlashCommand("remove", "Remove an existing Twitch stream from the server.")] 152 | public async Task ServerTwitchRemove( 153 | [Autocomplete(typeof(TwitchStreamAutocomplete))] 154 | [Summary("twitchname", "Stream name you'd like to unsubscribe from.")] 155 | int streamId) 156 | { 157 | await DeferAsync(); 158 | 159 | var stream = _streamDb.TwitchStreams.FirstOrDefault(x => x.Id == streamId); 160 | if (stream == null) 161 | { 162 | await FollowupAsync("Failed to find stream."); 163 | return; 164 | } 165 | 166 | _streamDb.TwitchStreams.Remove(stream); 167 | await _streamDb.SaveChangesAsync(); 168 | 169 | _twitchClientService.RestartMonitor(); 170 | 171 | await FollowupAsync($"Successfully removed {Format.Bold(stream.TwitchName)}'s stream from server.", 172 | ephemeral: true); 173 | } 174 | }*/ 175 | } 176 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | README 2 | ================== 3 | 4 | [![Discord](https://img.shields.io/discord/960484926950637608?color=success&logo=Discord&logoColor=white)](https://discord.gg/JBBqF6Pw2z) 5 | [![License](https://img.shields.io/badge/license-AGPLv3-teal.svg)](https://choosealicense.com/licenses/agpl-3.0/) 6 | [![Build](https://github.com/devFelicity/Bot-Frontend/actions/workflows/build.yml/badge.svg)](https://github.com/devFelicity/Bot-Frontend/actions/workflows/build.yml) 7 | 8 | Welcome to the GitHub repository for **Felicity**, a powerful Discord bot designed for **Destiny 2** players. This bot provides various features and utilities to enhance your Destiny 2 gaming experience. Read on to learn more about its capabilities and how to install it. 9 | 10 | Features 11 | -------- 12 | 13 | 1. **Weapon Pattern Progress**: Keep track of your progress towards crafting different weapon patterns. 14 | 15 | 2. **Emblem Information**: Get detailed information about emblems, including rarity and detect account shares. 16 | 17 | 3. **Checkpoint Bot Information**: Access information from [d2checkpoint.com](https://d2checkpoint.com) to view available checkpoint bots. 18 | 19 | 4. **Guardians, Loadouts, and Collections**: Easily view information about your guardians, loadouts, and collections. 20 | 21 | 5. **Loot Tables**: Check the loot tables for various activities to plan your farming sessions effectively. 22 | 23 | 6. **Vendor Information**: Stay updated with the latest vendor information and sales. 24 | 25 | 7. **Recommended Rolls**: Get recommendations for optimal rolls on different weapons. 26 | 27 | 8. **Twitch Live Alerts**: Receive alerts when your favorite streamer goes live on Twitch. The alerts are edited to include a VOD link once the stream ends. 28 | 29 | Installation 30 | ------------ 31 | 32 | To add **Felicity** to your Discord server, follow these steps: 33 | 34 | 1. If you see the bot in a server, simply click on it and then click on "Add to Server". 35 | 36 | _OR_ 37 | 38 | Use the following link: [Invite Link](https://discord.com/api/oauth2/authorize?client_id=709475072158728283&permissions=17979270414400&scope=bot%20applications.commands) 39 | 40 | 2. You'll be redirected to Discord's authorization page. Select the server you want to add the bot to and click "Authorize". 41 | 42 | 3. Confirm any necessary permissions for the bot on the server. 43 | 44 | 4. Congratulations! **Felicity** is now added to your Discord server. 45 | 46 | Usage 47 | ----- 48 | 49 | Once **Felicity** is added to your Discord server, you can start using its features. Here are a few example commands to get you started: 50 | 51 | * To check your craftable weapon pattern progress: 52 | 53 | ```diff 54 | /recipes 55 | ``` 56 | 57 | * To view your top 5 rarest emblems: 58 | 59 | ```diff 60 | /emblem rarest 61 | ``` 62 | 63 | * To get checkpoint bot information: 64 | 65 | ```diff 66 | /checkpoint-list 67 | ``` 68 | 69 | * To view loot tables for activities, uses Discord auto-complete to show available activities: 70 | 71 | ```diff 72 | /loot-table 73 | ``` 74 | 75 | * To look up recommended rolls for a specific weapon, uses Discord auto-complete to show available weapons: 76 | 77 | ```diff 78 | /roll-finder 79 | ``` 80 | 81 | * To receive Twitch live alerts: 82 | 83 | ```diff 84 | /server twitch add [options] 85 | ``` 86 | 87 | For a complete list of available commands and their usage, [check here](https://tryfelicity.one/commands/). 88 | 89 | Contributing 90 | ------------ 91 | 92 | If you'd like to contribute to the development of **Felicity**, please follow the guidelines outlined in the [CONTRIBUTING.md](CONTRIBUTING.md) file. We welcome any contributions, including bug fixes, feature enhancements, and documentation improvements. 93 | 94 | Thanks goes to these wonderful people ([emoji key](https://allcontributors.org/docs/en/emoji-key)): 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 |
Willow
Willow

💻 💵
MeGl
MeGl

💻 🧑‍🏫
Zempp
Zempp

🔣
calmqq
calmqq

🔣
TheLastJoaquin
TheLastJoaquin

🔣
Subhaven
Subhaven

🎨
Barry Briggs
Barry Briggs

💵
Kat Michaela
Kat Michaela

🔧 🧑‍🏫
camo
camo

💵 🚇
116 | 117 | 118 | 119 | 120 | 121 | 122 | Support 123 | ------- 124 | 125 | If you encounter any issues or have questions about **Felicity**, please reach out to our support team by creating an issue in the [GitHub repository](https://github.com/devFelicity/Bot-Frontend/issues/new/choose) or by contacting us via the [support server](https://discord.gg/JBBqF6Pw2z). 126 | 127 | License 128 | ------- 129 | 130 | This project is licensed under [AGPLv3](https://www.gnu.org/licenses/agpl-3.0.en.html). Please see the [LICENSE.md](https://github.com/devFelicity/Bot-Frontend/blob/main/LICENSE.md) file for more details. 131 | 132 | --- 133 | 134 | Thank you for choosing **Felicity**! We hope this bot enhances your Destiny 2 gaming experience. If you have any feedback or suggestions, please don't hesitate to let us know. Happy gaming! 135 | -------------------------------------------------------------------------------- /Felicity/Util/EmoteHelper.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using Discord; 3 | using Discord.WebSocket; 4 | using DotNetBungieAPI.Models.Destiny; 5 | using DotNetBungieAPI.Models.Destiny.Definitions.InventoryItems; 6 | using Felicity.Models.Caches; 7 | using Emote = Felicity.Models.Caches.Emote; 8 | 9 | namespace Felicity.Util; 10 | 11 | internal static class EmoteHelper 12 | { 13 | private const string FilePath = "Data/emoteCache.json"; 14 | 15 | private static EmoteCache GetEmoteCache() 16 | { 17 | var result = JsonSerializer.Deserialize(File.ReadAllText(FilePath)) ?? new EmoteCache(); 18 | 19 | return result; 20 | } 21 | 22 | private static void WriteEmoteCache(EmoteCache cache) 23 | { 24 | File.WriteAllText(FilePath, JsonSerializer.Serialize(cache)); 25 | } 26 | 27 | private static GuildEmote? AddEmote(BaseSocketClient discordClient, string imageUrl, string name) 28 | { 29 | var emoteSettings = GetEmoteCache(); 30 | 31 | // ReSharper disable once LoopCanBeConvertedToQuery 32 | foreach (var serverId in emoteSettings.Settings.ServerIDs!) 33 | { 34 | var server = discordClient.GetGuild(serverId); 35 | 36 | if (server != null && server.Emotes.Count >= 50) 37 | continue; 38 | 39 | var imageBytes = new HttpClient().GetByteArrayAsync($"{BotVariables.BungieBaseUrl}{imageUrl}").Result; 40 | var emote = server?.CreateEmoteAsync(name, new Image(new MemoryStream(imageBytes))).Result; 41 | return emote; 42 | } 43 | 44 | return null; 45 | } 46 | 47 | public static GuildEmote? GetEmote(BaseSocketClient discordClient, string imageUrl, string name, uint? valuePerkId) 48 | { 49 | var emoteConfig = GetEmoteCache(); 50 | 51 | if (valuePerkId == 0) 52 | foreach (var settingsServerId in emoteConfig.Settings.ServerIDs!) 53 | { 54 | var serverMotes = discordClient.GetGuild(settingsServerId).Emotes; 55 | foreach (var serverMote in serverMotes) 56 | if (serverMote.Name == name) 57 | return serverMote; 58 | } 59 | 60 | emoteConfig.Settings.Emotes ??= new Dictionary(); 61 | 62 | if (valuePerkId == null) 63 | return null; 64 | 65 | if (emoteConfig.Settings.Emotes.ContainsKey((uint)valuePerkId)) 66 | foreach (var settingsServerId in emoteConfig.Settings.ServerIDs!) 67 | { 68 | var serverMotes = discordClient.GetGuild(settingsServerId).Emotes; 69 | var emotes = serverMotes.FirstOrDefault(x => x.Id == emoteConfig.Settings.Emotes[(uint)valuePerkId].Id); 70 | if (emotes != null) 71 | return emotes; 72 | } 73 | 74 | if (emoteConfig.Settings.Emotes.ContainsKey((uint)valuePerkId)) 75 | emoteConfig.Settings.Emotes.Remove((uint)valuePerkId); 76 | 77 | name = name.Replace(" ", "").Replace("-", "").Replace("'", ""); 78 | 79 | var result = AddEmote(discordClient, imageUrl, name); 80 | 81 | if (result == null) 82 | return result; 83 | 84 | emoteConfig.Settings.Emotes.Add((uint)valuePerkId, new Emote { Id = result.Id, Name = result.Name }); 85 | WriteEmoteCache(emoteConfig); 86 | return result; 87 | } 88 | 89 | public static string StaticEmote(string input) 90 | { 91 | var output = input.ToLower() switch 92 | { 93 | "primary" => "<:primary:1006946858318434364>", 94 | "special" => "<:special:1006946861078290523>", 95 | "heavy" => "<:heavy:1006946859610296510>", 96 | "pve" => "<:pve:1006939958013079643>", 97 | "pvp" => "<:pvp:1006939951843246120>", 98 | "arc" => "<:arc:1006939955718783098>", 99 | "solar" => "<:solar:1006939954364039228>", 100 | "void" => "<:void:1006939953139294269>", 101 | "stasis" => "<:stasis:1006939956901580894>", 102 | "reload" => "<:reload:1012036849239339038>", 103 | "range" => "<:range:1012038268285632572>", 104 | "handling" => "<:handling:1012038277726994472>", 105 | "stability" => "<:stability:1012038265911644312>", 106 | "accuracy" => "<:accuracy:1012038270026272871>", 107 | "damage" => "<:damage:1012038275269152870>", 108 | "draw_time" => "<:draw_time:1012038272106647562>", 109 | "charge_time" => "<:charge_time:1012038273583038546>", 110 | "projectile_speed" => "<:projectile_speed:1012038279887065209>", 111 | "shield_duration" => "<:shield_duration:1012038262006755488>", 112 | "blast_radius" => "<:blast_radius:1012038264158425109>", 113 | "barrier" => "<:barrier:1021748090954317917>", 114 | "overload" => "<:overload:1021748088299331736>", 115 | "unstop" => "<:unstoppable:1021748089645699102>", 116 | "helmet" => "<:helmet:996490149728899122>", 117 | "gloves" => "<:gloves:996490148025995385>", 118 | "chest" => "<:chest:996490146922901655>", 119 | "boots" => "<:boots:996490145224200292>", 120 | "class" => "<:class:996490144066572288>", 121 | "pattern" => "<:pattern:1025358845649891409>", 122 | _ => "" 123 | }; 124 | 125 | return output; 126 | } 127 | 128 | public static string GetItemType(DestinyInventoryItemDefinition manifestItem) 129 | { 130 | var result = manifestItem.ItemSubType switch 131 | { 132 | DestinyItemSubType.None => manifestItem.ItemType switch 133 | { 134 | DestinyItemType.Vehicle => "<:SW:996727310805893181> ", 135 | DestinyItemType.Ship => "<:SP:996727309069471815> ", 136 | _ => string.Empty 137 | }, 138 | _ => GetItemType(manifestItem.ItemSubType) 139 | }; 140 | 141 | return result != string.Empty ? result : "<:CS:996724235634491523> "; 142 | } 143 | 144 | public static string GetItemType(DestinyItemSubType? weaponDestinyItemType) 145 | { 146 | var result = weaponDestinyItemType switch 147 | { 148 | DestinyItemSubType.AutoRifle => "<:AR:996495566521520208> ", 149 | DestinyItemSubType.Shotgun => "<:SG:996495567825948672> ", 150 | DestinyItemSubType.Machinegun => "<:LMG:996495568887087196> ", 151 | DestinyItemSubType.HandCannon => "<:HC:996492277373476906> ", 152 | DestinyItemSubType.RocketLauncher => "<:RL:996493601083244695> ", 153 | DestinyItemSubType.FusionRifle => "<:FR:996495565082873976> ", 154 | DestinyItemSubType.SniperRifle => "<:SR:996492271212040243> ", 155 | DestinyItemSubType.PulseRifle => "<:PR:996493599871078491> ", 156 | DestinyItemSubType.ScoutRifle => "<:ScR:996492274953371769> ", 157 | DestinyItemSubType.Sidearm => "<:SA:996492272411619470> ", 158 | DestinyItemSubType.Sword => "<:SRD:996492273795727361> ", 159 | DestinyItemSubType.FusionRifleLine => "<:LFR:996497905865195540> ", 160 | DestinyItemSubType.GrenadeLauncher => "<:GL:996492276228436000> ", 161 | DestinyItemSubType.SubmachineGun => "<:SMG:996493598495359096> ", 162 | DestinyItemSubType.TraceRifle => "<:TR:996495569650466929> ", 163 | DestinyItemSubType.Bow => "<:BW:996493602354114640> ", 164 | DestinyItemSubType.Glaive => "<:GV:996495571126845491> ", 165 | DestinyItemSubType.HelmetArmor => $"{StaticEmote("helmet")} ", 166 | DestinyItemSubType.GauntletsArmor => $"{StaticEmote("gloves")} ", 167 | DestinyItemSubType.ChestArmor => $"{StaticEmote("chest")} ", 168 | DestinyItemSubType.LegArmor => $"{StaticEmote("boots")} ", 169 | DestinyItemSubType.ClassArmor => $"{StaticEmote("class")} ", 170 | _ => string.Empty 171 | }; 172 | 173 | return result != string.Empty ? result : "<:CS:996724235634491523> "; 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /Felicity/Program.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.WebSocket; 3 | using DotNetBungieAPI; 4 | using DotNetBungieAPI.AspNet.Security.OAuth.Providers; 5 | using DotNetBungieAPI.DefinitionProvider.Sqlite; 6 | using DotNetBungieAPI.Extensions; 7 | using DotNetBungieAPI.Models; 8 | using DotNetBungieAPI.Models.Applications; 9 | using DotNetBungieAPI.Models.Destiny; 10 | using Felicity.Extensions; 11 | using Felicity.Models; 12 | using Felicity.Options; 13 | using Felicity.Services; 14 | using Felicity.Services.Hosted; 15 | using Felicity.Util; 16 | using Microsoft.AspNetCore.Authentication.Cookies; 17 | using Microsoft.AspNetCore.Authentication.OAuth; 18 | using Serilog; 19 | using Serilog.Events; 20 | 21 | Log.Logger = new LoggerConfiguration() 22 | .Enrich.FromLogContext() 23 | .MinimumLevel.Debug() 24 | .MinimumLevel.Override("Quartz", LogEventLevel.Information) 25 | .WriteTo.Console() 26 | .WriteTo.File("Logs/latest-.log", rollingInterval: RollingInterval.Day, retainedFileCountLimit: 14) 27 | .CreateLogger(); 28 | 29 | try 30 | { 31 | await BotVariables.Initialize(); 32 | var builder = WebApplication.CreateBuilder(args); 33 | var title = $"Starting Felicity v.{BotVariables.Version} on {Environment.OSVersion}..."; 34 | Console.Title = title; 35 | Log.Information(title); 36 | 37 | if (!BotVariables.IsDebug) 38 | builder.WebHost.UseSentry(options => 39 | { 40 | options.AttachStacktrace = true; 41 | options.Dsn = builder.Configuration.GetSection("SentryDsn").Value; 42 | options.MinimumBreadcrumbLevel = LogLevel.Information; 43 | options.MinimumEventLevel = LogLevel.Warning; 44 | options.Release = $"FelicityOne@{BotVariables.Version}"; 45 | }); 46 | 47 | var bungieApiOptions = new BungieApiOptions(); 48 | builder.Configuration.GetSection("Bungie").Bind(bungieApiOptions); 49 | 50 | EnsureDirectoryExists(bungieApiOptions.ManifestPath!); 51 | EnsureDirectoryExists("Data"); 52 | 53 | builder.Host.UseSerilog((context, services, configuration) => 54 | { 55 | var serilogConfig = configuration 56 | .ReadFrom.Configuration(context.Configuration) 57 | .ReadFrom.Services(services) 58 | .Enrich.FromLogContext() 59 | .WriteTo.Console() 60 | .WriteTo.File("Logs/latest-.log", rollingInterval: RollingInterval.Day); 61 | 62 | if (!BotVariables.IsDebug) 63 | serilogConfig 64 | .WriteTo.Sentry(o => 65 | { 66 | o.AttachStacktrace = true; 67 | o.Dsn = builder.Configuration.GetSection("SentryDsn").Value; 68 | o.MinimumBreadcrumbLevel = LogEventLevel.Information; 69 | o.MinimumEventLevel = LogEventLevel.Warning; 70 | o.Release = $"FelicityOne@{BotVariables.Version}"; 71 | }); 72 | }); 73 | 74 | builder.Host.UseDefaultServiceProvider(o => o.ValidateScopes = false); 75 | 76 | builder.Services.AddDbContext(); 77 | builder.Services.AddDbContext(); 78 | builder.Services.AddDbContext(); 79 | builder.Services.AddDbContext(); 80 | 81 | builder.Services 82 | .AddDiscord( 83 | discordClient => 84 | { 85 | discordClient.GatewayIntents = GatewayIntents.AllUnprivileged & ~GatewayIntents.GuildInvites & 86 | ~GatewayIntents.GuildScheduledEvents; 87 | discordClient.AlwaysDownloadUsers = false; 88 | }, 89 | _ => { }, 90 | textCommandsService => { textCommandsService.CaseSensitiveCommands = false; }, 91 | builder.Configuration) 92 | .AddLogging(options => options.AddSerilog(dispose: true)) 93 | .UseBungieApiClient(bungieClient => 94 | { 95 | if (bungieApiOptions.ApiKey != null) 96 | bungieClient.ClientConfiguration.ApiKey = bungieApiOptions.ApiKey; 97 | 98 | bungieClient.ClientConfiguration.ApplicationScopes = ApplicationScopes.ReadUserData | 99 | ApplicationScopes.ReadBasicUserProfile | 100 | ApplicationScopes.ReadDestinyInventoryAndVault | 101 | ApplicationScopes.MoveEquipDestinyItems; 102 | 103 | bungieClient.ClientConfiguration.CacheDefinitions = true; 104 | bungieClient.ClientConfiguration.ClientId = bungieApiOptions.ClientId; 105 | 106 | if (bungieApiOptions.ClientSecret != null) 107 | bungieClient.ClientConfiguration.ClientSecret = bungieApiOptions.ClientSecret; 108 | 109 | bungieClient.ClientConfiguration.UsedLocales.Add(BungieLocales.EN); 110 | bungieClient 111 | .DefinitionProvider.UseSqliteDefinitionProvider(definitionProvider => 112 | { 113 | definitionProvider.ManifestFolderPath = bungieApiOptions.ManifestPath; 114 | definitionProvider.AutoUpdateManifestOnStartup = true; 115 | definitionProvider.FetchLatestManifestOnInitialize = true; 116 | definitionProvider.DeleteOldManifestDataAfterUpdates = true; 117 | }); 118 | bungieClient.DotNetBungieApiHttpClient.ConfigureDefaultHttpClient(options => 119 | options.SetRateLimitSettings(190, TimeSpan.FromSeconds(10))); 120 | bungieClient.DefinitionRepository.ConfigureDefaultRepository(x => 121 | { 122 | var defToIgnore = Enum.GetValues() 123 | .FirstOrDefault(y => y == DefinitionsEnum.DestinyTraitCategoryDefinition); 124 | 125 | x.IgnoreDefinitionType(defToIgnore); 126 | }); 127 | }) 128 | .AddHostedService() 129 | .AddSingleton>(); 130 | 131 | // builder.Services.Configure(builder.Configuration.GetSection("Twitch")).AddSingleton(); 132 | // builder.Services.AddHostedService(); 133 | builder.Services.AddHostedService(); 134 | builder.Services.AddHostedService(); 135 | 136 | builder.Services 137 | .AddAuthentication(options => 138 | { 139 | options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme; 140 | options.DefaultChallengeScheme = BungieNetAuthenticationDefaults.AuthenticationScheme; 141 | options.DefaultAuthenticateScheme = BungieNetAuthenticationDefaults.AuthenticationScheme; 142 | }) 143 | .AddCookie() 144 | .AddBungieNet(options => 145 | { 146 | options.ClientId = bungieApiOptions.ClientId.ToString(); 147 | options.ApiKey = bungieApiOptions.ApiKey!; 148 | options.ClientSecret = bungieApiOptions.ClientSecret!; 149 | options.Events = new OAuthEvents 150 | { 151 | OnCreatingTicket = oAuthCreatingTicketContext => 152 | { 153 | BungieAuthCacheService.TryAddContext(oAuthCreatingTicketContext); 154 | return Task.CompletedTask; 155 | } 156 | }; 157 | }); 158 | 159 | builder.Services.AddMvc(); 160 | builder.Services 161 | .AddControllers(options => { options.EnableEndpointRouting = false; }) 162 | .AddJsonOptions(x => { BungieAuthCacheService.Initialize(x.JsonSerializerOptions); }); 163 | builder.Services.AddCors(c => 164 | { 165 | c.AddPolicy("AllowOrigin", 166 | options => options.AllowAnyOrigin().AllowAnyHeader().AllowAnyMethod()); 167 | }); 168 | 169 | var app = builder.Build(); 170 | 171 | app.UseRouting(); 172 | 173 | if (!BotVariables.IsDebug) 174 | app.UseSentryTracing(); 175 | 176 | app.UseCookiePolicy(new CookiePolicyOptions 177 | { 178 | Secure = CookieSecurePolicy.Always 179 | }); 180 | app.UseAuthentication(); 181 | app.UseAuthorization(); 182 | app.MapControllers(); 183 | app.UseMvc(); 184 | // app.UseHttpsRedirection(); 185 | if (!app.Environment.IsDevelopment()) 186 | app.UseHsts(); 187 | 188 | app.MapGet("/health", () => Results.Ok()); 189 | 190 | await app.RunAsync(); 191 | } 192 | catch (Exception exception) 193 | { 194 | Log.Fatal(exception, "Host terminated unexpectedly"); 195 | } 196 | finally 197 | { 198 | Log.CloseAndFlush(); 199 | } 200 | 201 | return; 202 | 203 | static void EnsureDirectoryExists(string path) 204 | { 205 | if (!Directory.Exists(path)) 206 | Directory.CreateDirectory(path); 207 | } 208 | -------------------------------------------------------------------------------- /Felicity/DiscordCommands/Interactions/VendorCommands.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | using Discord; 3 | using Discord.Interactions; 4 | using DotNetBungieAPI.Extensions; 5 | using DotNetBungieAPI.HashReferences; 6 | using DotNetBungieAPI.Models.Destiny; 7 | using DotNetBungieAPI.Service.Abstractions; 8 | using Felicity.Models; 9 | using Felicity.Models.Caches; 10 | using Felicity.Util; 11 | 12 | // ReSharper disable UnusedMember.Global 13 | // ReSharper disable UnusedType.Global 14 | 15 | namespace Felicity.DiscordCommands.Interactions; 16 | 17 | [Preconditions.RequireOAuth] 18 | [Group("vendor", "Group of commands related to vendors and their available items.")] 19 | public class VendorCommands : InteractionModuleBase 20 | { 21 | private readonly IBungieClient _bungieClient; 22 | private readonly UserDb _userDb; 23 | 24 | public VendorCommands(UserDb userDb, IBungieClient bungieClient) 25 | { 26 | _userDb = userDb; 27 | _bungieClient = bungieClient; 28 | } 29 | 30 | [SlashCommand("gunsmith", "Fetch Banshee weapon inventory which includes D2Gunsmith and LightGG links.")] 31 | public async Task Gunsmith() 32 | { 33 | if (!await BungieApiUtils.CheckApi(_bungieClient)) 34 | throw new Exception("Bungie API is down or unresponsive."); 35 | 36 | var user = _userDb.Users.FirstOrDefault(x => x.DiscordId == Context.User.Id); 37 | if (user == null) 38 | { 39 | await FollowupAsync("Failed to fetch user profile."); 40 | return; 41 | } 42 | 43 | // if (!File.Exists($"Data/gsCache-{lg}.json")) 44 | // await FollowupAsync("Populating vendor data, this might take some time..."); 45 | 46 | var gsCache = await ProcessGunsmithData.FetchInventory(user, _bungieClient); 47 | 48 | if (gsCache != null) 49 | await FollowupAsync(embed: ProcessGunsmithData.BuildEmbed(gsCache, Context.Client)); 50 | else 51 | await FollowupAsync("An error occurred trying to build inventory."); 52 | } 53 | 54 | [SlashCommand("xur", "Fetch Xûr inventory which includes D2Gunsmith and LightGG links.")] 55 | public async Task Xur() 56 | { 57 | if (!await BungieApiUtils.CheckApi(_bungieClient)) 58 | throw new Exception("Bungie API is down or unresponsive."); 59 | 60 | if (ProcessXurData.IsXurHere()) 61 | { 62 | var user = _userDb.Users.FirstOrDefault(x => x.DiscordId == Context.User.Id); 63 | if (user == null) 64 | { 65 | await FollowupAsync("Failed to fetch user profile."); 66 | return; 67 | } 68 | 69 | // if (!File.Exists($"Data/xurCache-{lg}.json")) 70 | // await FollowupAsync("Populating vendor data, this might take some time..."); 71 | 72 | var xurCache = await ProcessXurData.FetchInventory(user, _bungieClient); 73 | 74 | if (xurCache != null) 75 | await FollowupAsync(embed: ProcessXurData.BuildEmbed(xurCache, Context.Client)); 76 | else 77 | await FollowupAsync("An error occurred trying to build inventory."); 78 | } 79 | else 80 | { 81 | ProcessXurData.ClearCache(); 82 | 83 | await FollowupAsync(embed: ProcessXurData.BuildUnavailableEmbed()); 84 | } 85 | } 86 | 87 | [SlashCommand("ada-1", "Get list of shaders currently available at Ada-1.")] 88 | public async Task ShAda1() 89 | { 90 | if (!await BungieApiUtils.CheckApi(_bungieClient)) 91 | throw new Exception("Bungie API is down or unresponsive."); 92 | 93 | var user = _userDb.Users.FirstOrDefault(x => x.DiscordId == Context.User.Id); 94 | if (user == null) 95 | { 96 | await FollowupAsync("Failed to fetch user profile."); 97 | return; 98 | } 99 | 100 | var characterIdTask = await _bungieClient.ApiAccess.Destiny2.GetProfile(user.DestinyMembershipType, 101 | user.DestinyMembershipId, new[] 102 | { 103 | DestinyComponentType.Characters 104 | }); 105 | 106 | var vendorData = await _bungieClient.ApiAccess.Destiny2.GetVendor(user.DestinyMembershipType, 107 | user.DestinyMembershipId, characterIdTask.Response.Characters.Data.Keys.First(), 108 | DefinitionHashes.Vendors.Ada1_350061650, new[] 109 | { 110 | DestinyComponentType.VendorCategories, DestinyComponentType.VendorSales 111 | }, 112 | user.GetTokenData()); 113 | 114 | var shaderIndexes = vendorData.Response.Categories.Data.Categories.ElementAt(1).ItemIndexes; 115 | 116 | var responseString = new StringBuilder(); 117 | 118 | foreach (var shaderIndex in shaderIndexes) 119 | { 120 | var reward = vendorData.Response.Sales.Data[shaderIndex]; 121 | 122 | if (reward.Quantity != 1) 123 | continue; 124 | 125 | if (reward.Item.Hash is DefinitionHashes.InventoryItems.UpgradeModule) 126 | continue; 127 | 128 | if (reward.Item.Select(x => x.ItemSubType != DestinyItemSubType.Shader)) 129 | continue; 130 | 131 | responseString.Append(reward.SaleStatus == VendorItemStatus.Success ? "❌" : "✅"); 132 | responseString.Append( 133 | $" [{reward.Item.Select(x => x.DisplayProperties.Name)}]({MiscUtils.GetLightGgLink(reward.Item.Select(x => x.Hash))})\n"); 134 | } 135 | 136 | var embed = Embeds.MakeBuilder(); 137 | 138 | embed.Author = new EmbedAuthorBuilder 139 | { 140 | Name = "Ada-1, Armor Synthesis", 141 | IconUrl = BotVariables.Images.AdaVendorLogo 142 | }; 143 | 144 | embed.Description = "Ada-1 is selling shaders that have been unavailable for quite some time,\n\n" + 145 | "These cost 10,000 Glimmer each, but keep in mind that she'll only be offering 3 shaders per week during Season 21."; 146 | 147 | embed.AddField("Shaders", responseString.ToString()); 148 | 149 | await FollowupAsync(embed: embed.Build()); 150 | } 151 | 152 | [SlashCommand("saint14", "Fetch Saint-14 (Trials of Osiris) reputation rewards for the week.")] 153 | public async Task Saint14() 154 | { 155 | if (!await BungieApiUtils.CheckApi(_bungieClient)) 156 | throw new Exception("Bungie API is down or unresponsive."); 157 | 158 | var user = _userDb.Users.FirstOrDefault(x => x.DiscordId == Context.User.Id); 159 | if (user == null) 160 | { 161 | await FollowupAsync("Failed to fetch user profile."); 162 | return; 163 | } 164 | 165 | var characterIdTask = await _bungieClient.ApiAccess.Destiny2.GetProfile(user.DestinyMembershipType, 166 | user.DestinyMembershipId, new[] 167 | { 168 | DestinyComponentType.Characters 169 | }); 170 | 171 | var vendorData = await _bungieClient.ApiAccess.Destiny2.GetVendor(user.DestinyMembershipType, 172 | user.DestinyMembershipId, characterIdTask.Response.Characters.Data.Keys.First(), 173 | DefinitionHashes.Vendors.Saint14, new[] 174 | { 175 | DestinyComponentType.ItemPerks, DestinyComponentType.ItemSockets, 176 | DestinyComponentType.Vendors, DestinyComponentType.VendorCategories, 177 | DestinyComponentType.VendorSales 178 | }, 179 | user.GetTokenData()); 180 | 181 | if (vendorData.Response.Vendor.Data.Progression.CurrentResetCount >= 3) 182 | { 183 | var errorEmbed = Embeds.MakeBuilder(); 184 | errorEmbed.Title = "Saint-14"; 185 | errorEmbed.ThumbnailUrl = BotVariables.Images.SaintVendorLogo; 186 | 187 | errorEmbed.Description = 188 | $"Because you've reset your rank {Format.Bold(vendorData.Response.Vendor.Data.Progression.CurrentResetCount.ToString())} times, " + 189 | "Saint-14 no longer sells weapons with fixed rolls for you."; 190 | 191 | await FollowupAsync(embed: errorEmbed.Build(), ephemeral: true); 192 | return; 193 | } 194 | 195 | var repRewards = vendorData.Response.Categories.Data.Categories.ElementAt(1).ItemIndexes; 196 | 197 | var embed = Embeds.MakeBuilder(); 198 | embed.Color = Color.Purple; 199 | 200 | embed.Author = new EmbedAuthorBuilder 201 | { 202 | Name = "Saint-14", 203 | IconUrl = BotVariables.Images.SaintVendorLogo 204 | }; 205 | 206 | embed.Description = 207 | "A legendary hero and the former Titan Vanguard, Saint-14 now manages the PvP game mode Trials of Osiris.\n\n" 208 | + "These rewards change perks on weekly reset."; 209 | 210 | var i = 0; 211 | 212 | foreach (var repReward in repRewards) 213 | { 214 | var reward = vendorData.Response.Sales.Data[repReward]; 215 | 216 | if (reward.Quantity != 1) 217 | continue; 218 | 219 | if (reward.Item.Hash is DefinitionHashes.InventoryItems.PowerfulTrialsGear 220 | or DefinitionHashes.InventoryItems.ResetRank_1514009869 221 | or DefinitionHashes.InventoryItems.ResetRank_2133694745) 222 | continue; 223 | 224 | if (reward.Item.Select(x => x.ItemType != DestinyItemType.Weapon)) 225 | continue; 226 | 227 | var fullMessage = 228 | $"{EmoteHelper.GetItemType(reward.Item.Select(x => x.ItemSubType))} " + 229 | $"[{reward.Item.Select(x => x.DisplayProperties.Name)}]" + 230 | $"({MiscUtils.GetLightGgLink(reward.Item.Select(x => x.Hash))})\n"; 231 | 232 | for (var j = 0; j < 4; j++) 233 | { 234 | var plug = vendorData.Response.ItemComponents.Sockets.Data[repReward].Sockets.ElementAt(j + 1).Plug; 235 | 236 | fullMessage += EmoteHelper.GetEmote(Context.Client, 237 | plug.Select(x => x.DisplayProperties.Icon.RelativePath), 238 | plug.Select(x => x.DisplayProperties.Name), plug.Select(x => x.Hash)); 239 | } 240 | 241 | var fieldName = i == 0 ? "Rank 10" : "Rank 16"; 242 | 243 | embed.AddField(new EmbedFieldBuilder 244 | { 245 | IsInline = true, 246 | Name = fieldName, 247 | Value = fullMessage 248 | }); 249 | 250 | i++; 251 | } 252 | 253 | embed.AddField(new EmbedFieldBuilder 254 | { 255 | IsInline = true, 256 | Name = "Current Rank", 257 | Value = vendorData.Response.Vendor.Data.Progression.Level 258 | }); 259 | 260 | await FollowupAsync(embed: embed.Build()); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /Felicity/Util/AutoCompletes.cs: -------------------------------------------------------------------------------- 1 | using Discord; 2 | using Discord.Interactions; 3 | using DotNetBungieAPI.Extensions; 4 | using DotNetBungieAPI.Models.Destiny.Definitions.Metrics; 5 | using DotNetBungieAPI.Service.Abstractions; 6 | using Felicity.Models; 7 | using Felicity.Models.Caches; 8 | using Felicity.Util.Enums; 9 | using Microsoft.EntityFrameworkCore; 10 | 11 | namespace Felicity.Util; 12 | 13 | public class TwitchStreamAutocomplete : AutocompleteHandler 14 | { 15 | private readonly TwitchStreamDb _streamDb; 16 | 17 | public TwitchStreamAutocomplete(TwitchStreamDb streamDb) 18 | { 19 | _streamDb = streamDb; 20 | } 21 | 22 | public override async Task GenerateSuggestionsAsync( 23 | IInteractionContext context, 24 | IAutocompleteInteraction autocompleteInteraction, 25 | IParameterInfo parameter, 26 | IServiceProvider services) 27 | { 28 | var streamList = await _streamDb.TwitchStreams.Where(stream => stream.ServerId == context.Guild.Id) 29 | .ToListAsync(); 30 | 31 | if (streamList.Count == 0) 32 | return AutocompletionResult.FromError(InteractionCommandError.Unsuccessful, "No streams found."); 33 | 34 | var resultList = streamList.Select(twitchStream => new AutocompleteResult 35 | { 36 | Name = $"{twitchStream.TwitchName} ({context.Guild.GetChannelAsync(twitchStream.ChannelId).Result.Name})", 37 | Value = twitchStream.Id 38 | }).ToList(); 39 | 40 | return AutocompletionResult.FromSuccess(resultList); 41 | } 42 | } 43 | 44 | public class MetricAutocomplete : AutocompleteHandler 45 | { 46 | private readonly IBungieClient _bungieClient; 47 | 48 | public MetricAutocomplete(IBungieClient bungieClient) 49 | { 50 | _bungieClient = bungieClient; 51 | } 52 | 53 | public override Task GenerateSuggestionsAsync( 54 | IInteractionContext context, 55 | IAutocompleteInteraction autocompleteInteraction, 56 | IParameterInfo parameter, 57 | IServiceProvider services) 58 | { 59 | var resultList = new List(); 60 | 61 | var currentSearch = autocompleteInteraction.Data.Current.Value.ToString(); 62 | 63 | var metricsList = _bungieClient.Repository.GetAll(); 64 | 65 | if (currentSearch != null) 66 | resultList.AddRange(from destinyMetricDefinition in metricsList 67 | where destinyMetricDefinition.DisplayProperties.Name.ToLower().Contains(currentSearch.ToLower()) 68 | select new AutocompleteResult( 69 | $"{destinyMetricDefinition.DisplayProperties.Name} ({destinyMetricDefinition.Traits.Last().Select(x => x.DisplayProperties.Name)})", 70 | destinyMetricDefinition.Hash)); 71 | else 72 | resultList.AddRange(from destinyMetricDefinition in metricsList 73 | select new AutocompleteResult( 74 | $"{destinyMetricDefinition.DisplayProperties.Name} ({destinyMetricDefinition.Traits.Last().Select(x => x.DisplayProperties.Name)})", 75 | destinyMetricDefinition.Hash)); 76 | 77 | return Task.FromResult( 78 | AutocompletionResult.FromSuccess(resultList.OrderBy(_ => Random.Shared.Next()).Take(25))); 79 | } 80 | } 81 | 82 | public class MementoWeaponAutocomplete : AutocompleteHandler 83 | { 84 | public override async Task GenerateSuggestionsAsync( 85 | IInteractionContext context, 86 | IAutocompleteInteraction autocompleteInteraction, 87 | IParameterInfo parameter, 88 | IServiceProvider services) 89 | { 90 | var source = (from autocompleteOption in autocompleteInteraction.Data.Options 91 | where autocompleteOption.Name == "source" 92 | select Enum.Parse(autocompleteOption.Value.ToString() ?? string.Empty)).FirstOrDefault(); 93 | 94 | var memCache = await ProcessMementoData.ReadJsonAsync(); 95 | 96 | if (memCache == null) 97 | return AutocompletionResult.FromError(InteractionCommandError.Unsuccessful, "Memento cache not found."); 98 | 99 | var goodSource = memCache.MementoInventory?.FirstOrDefault(x => x.Source == source); 100 | 101 | if (goodSource?.WeaponList == null) 102 | return AutocompletionResult.FromError(InteractionCommandError.Unsuccessful, "Memento cache not found."); 103 | 104 | var currentSearch = autocompleteInteraction.Data.Current.Value.ToString(); 105 | 106 | var results = (from weapon in goodSource.WeaponList 107 | where currentSearch == null || weapon.WeaponName!.ToLower().Contains(currentSearch.ToLower()) 108 | select new AutocompleteResult { Name = weapon.WeaponName, Value = weapon.WeaponName }).ToList(); 109 | 110 | results = results.OrderBy(x => x.Name).ToList(); 111 | 112 | return AutocompletionResult.FromSuccess(results); 113 | } 114 | } 115 | 116 | public class LootTableAutocomplete : AutocompleteHandler 117 | { 118 | public override Task GenerateSuggestionsAsync( 119 | IInteractionContext context, 120 | IAutocompleteInteraction autocompleteInteraction, 121 | IParameterInfo parameter, 122 | IServiceProvider services) 123 | { 124 | var resultList = new List(); 125 | 126 | var currentSearch = autocompleteInteraction.Data.Current.Value.ToString(); 127 | 128 | resultList.AddRange(currentSearch != null 129 | ? LootTables.KnownTables.Where(autocompleteResult => 130 | autocompleteResult.Name!.ToLower().Contains(currentSearch.ToLower())) 131 | : LootTables.KnownTables); 132 | 133 | var autocompleteList = resultList 134 | .Select(lootTable => new AutocompleteResult($"{lootTable.ActivityType}: {lootTable.Name}", lootTable.Name)) 135 | .ToList(); 136 | 137 | autocompleteList = autocompleteList.OrderBy(x => x.Name).ToList(); 138 | 139 | return Task.FromResult(AutocompletionResult.FromSuccess(autocompleteList)); 140 | } 141 | } 142 | 143 | public class WishAutocomplete : AutocompleteHandler 144 | { 145 | public override Task GenerateSuggestionsAsync( 146 | IInteractionContext context, 147 | IAutocompleteInteraction autocompleteInteraction, 148 | IParameterInfo parameter, 149 | IServiceProvider services) 150 | { 151 | var resultList = new List(); 152 | 153 | var currentSearch = autocompleteInteraction.Data.Current.Value.ToString(); 154 | 155 | resultList.AddRange(currentSearch != null 156 | ? Wishes.KnownWishes.Where(autocompleteResult => 157 | autocompleteResult.Description!.ToLower().Contains(currentSearch.ToLower())) 158 | : Wishes.KnownWishes); 159 | 160 | var autocompleteList = resultList 161 | .Select(wish => new AutocompleteResult($"Wish {wish.Number}: {wish.Description}", wish.Number)).ToList(); 162 | 163 | return Task.FromResult(AutocompletionResult.FromSuccess(autocompleteList)); 164 | } 165 | } 166 | 167 | public class RollFinderAutocomplete : AutocompleteHandler 168 | { 169 | public override async Task GenerateSuggestionsAsync( 170 | IInteractionContext context, 171 | IAutocompleteInteraction autocompleteInteraction, 172 | IParameterInfo parameter, 173 | IServiceProvider services) 174 | { 175 | var weaponList = await ProcessRollData.FromJsonAsync(); 176 | 177 | if (weaponList == null) 178 | return AutocompletionResult.FromError(InteractionCommandError.ParseFailed, "Failed to parse weapon rolls."); 179 | 180 | var mode = Convert.ToInt32(autocompleteInteraction.Data.Options.First().Value); 181 | 182 | var rollList = mode switch 183 | { 184 | 0 => weaponList.PvE, 185 | 1 => weaponList.PvP, 186 | _ => null 187 | }; 188 | 189 | if (rollList == null) 190 | return AutocompletionResult.FromError(InteractionCommandError.ParseFailed, "Failed to parse weapon rolls."); 191 | 192 | var currentSearch = autocompleteInteraction.Data.Current.Value.ToString(); 193 | 194 | var resultList = new List(); 195 | 196 | if (string.IsNullOrEmpty(currentSearch)) 197 | resultList.AddRange(rollList); 198 | else 199 | foreach (var roll in rollList.Where(roll => 200 | roll.WeaponName != null && roll.WeaponName.ToLower().Contains(currentSearch.ToLower()))) 201 | if (!resultList.Select(r => r.WeaponName == roll.WeaponName).Any()) 202 | resultList.Add(roll); 203 | 204 | var autocompleteList = 205 | resultList.Select(roll => new AutocompleteResult(roll.WeaponName, roll.WeaponId)).ToList(); 206 | 207 | return AutocompletionResult.FromSuccess(string.IsNullOrEmpty(currentSearch) 208 | ? autocompleteList.OrderBy(_ => Random.Shared.Next()).Take(25) 209 | : autocompleteList.OrderBy(x => x.Name).Take(25)); 210 | } 211 | } 212 | 213 | public class CheckpointAutocomplete : AutocompleteHandler 214 | { 215 | public override async Task GenerateSuggestionsAsync( 216 | IInteractionContext context, 217 | IAutocompleteInteraction autocompleteInteraction, 218 | IParameterInfo parameter, 219 | IServiceProvider services) 220 | { 221 | var currentSearch = autocompleteInteraction.Data.Current.Value.ToString(); 222 | 223 | var checkpointList = await CheckpointParser.FetchAsync(); 224 | if (checkpointList == null) 225 | return AutocompletionResult.FromError(InteractionCommandError.Unsuccessful, 226 | "Failed to fetch checkpoint list."); 227 | // WHY AM I REQUIRED TO INCLUDE A REASON WHEN DISCORD 228 | // HAS BEEN IGNORING THIS PARAMETER EVER SINCE THE RELEASE OF AUTO-COMPLETES??? 229 | 230 | var autocompleteList = new List(); 231 | if (string.IsNullOrEmpty(currentSearch)) 232 | autocompleteList.AddRange(checkpointList.Official?.Select(officialCp => 233 | new AutocompleteResult($"{officialCp.Activity} - {officialCp.Encounter}", 234 | officialCp.DisplayOrder)) ?? 235 | Enumerable.Empty()); 236 | else 237 | autocompleteList.AddRange(from officialCp in checkpointList.Official 238 | where $"{officialCp.Activity} {officialCp.Encounter}".ToLower().Contains(currentSearch.ToLower()) 239 | select new AutocompleteResult($"{officialCp.Activity} - {officialCp.Encounter}", 240 | officialCp.DisplayOrder)); 241 | 242 | return AutocompletionResult.FromSuccess(string.IsNullOrEmpty(currentSearch) 243 | ? autocompleteList 244 | : autocompleteList.OrderBy(x => x.Name)); 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /Felicity/Models/Clarity.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using Felicity.Util; 3 | using Newtonsoft.Json; 4 | using Newtonsoft.Json.Converters; 5 | 6 | // ReSharper disable ClassNeverInstantiated.Global 7 | 8 | namespace Felicity.Models; 9 | 10 | public class Clarity 11 | { 12 | [JsonProperty("hash")] 13 | public long Hash { get; set; } 14 | 15 | [JsonProperty("name")] 16 | public string? Name { get; set; } 17 | 18 | [JsonProperty("lastUpdate")] 19 | public long LastUpdate { get; set; } 20 | 21 | [JsonProperty("updatedBy")] 22 | public string? UpdatedBy { get; set; } 23 | 24 | [JsonProperty("type")] 25 | public TypeEnum Type { get; set; } 26 | 27 | [JsonProperty("description")] 28 | public string? Description { get; set; } 29 | 30 | [JsonProperty("stats", NullValueHandling = NullValueHandling.Ignore)] 31 | public Stats? Stats { get; set; } 32 | 33 | [JsonProperty("itemHash", NullValueHandling = NullValueHandling.Ignore)] 34 | public long? ItemHash { get; set; } 35 | 36 | [JsonProperty("itemName", NullValueHandling = NullValueHandling.Ignore)] 37 | public string? ItemName { get; set; } 38 | 39 | [JsonProperty("investmentStatOnly", NullValueHandling = NullValueHandling.Ignore)] 40 | public bool? InvestmentStatOnly { get; set; } 41 | } 42 | 43 | public class Stats 44 | { 45 | [JsonProperty("damage", NullValueHandling = NullValueHandling.Ignore)] 46 | public Stat[]? Damage { get; set; } 47 | 48 | [JsonProperty("range", NullValueHandling = NullValueHandling.Ignore)] 49 | public Stat[]? Range { get; set; } 50 | 51 | [JsonProperty("handling", NullValueHandling = NullValueHandling.Ignore)] 52 | public Stat[]? Handling { get; set; } 53 | 54 | [JsonProperty("reload", NullValueHandling = NullValueHandling.Ignore)] 55 | public Stat[]? Reload { get; set; } 56 | 57 | [JsonProperty("stability", NullValueHandling = NullValueHandling.Ignore)] 58 | public Stat[]? Stability { get; set; } 59 | 60 | [JsonProperty("aimAssist", NullValueHandling = NullValueHandling.Ignore)] 61 | public Stat[]? AimAssist { get; set; } 62 | 63 | [JsonProperty("chargeDraw", NullValueHandling = NullValueHandling.Ignore)] 64 | public Stat[]? ChargeDraw { get; set; } 65 | 66 | [JsonProperty("chargeDrawTime", NullValueHandling = NullValueHandling.Ignore)] 67 | public Stat[]? ChargeDrawTime { get; set; } 68 | 69 | [JsonProperty("draw", NullValueHandling = NullValueHandling.Ignore)] 70 | public Stat[]? Draw { get; set; } 71 | 72 | [JsonProperty("zoom", NullValueHandling = NullValueHandling.Ignore)] 73 | public Stat[]? Zoom { get; set; } 74 | 75 | [JsonProperty("stow", NullValueHandling = NullValueHandling.Ignore)] 76 | public Stat[]? Stow { get; set; } 77 | } 78 | 79 | public class StatType 80 | { 81 | [JsonProperty("stat")] 82 | public long[]? Stat { get; set; } 83 | 84 | [JsonProperty("multiplier", NullValueHandling = NullValueHandling.Ignore)] 85 | public double[]? Multiplier { get; set; } 86 | } 87 | 88 | public class Stat 89 | { 90 | [JsonProperty("active", NullValueHandling = NullValueHandling.Ignore)] 91 | public StatType? Active { get; set; } 92 | 93 | [JsonProperty("passive", NullValueHandling = NullValueHandling.Ignore)] 94 | public StatType? Passive { get; set; } 95 | } 96 | 97 | public enum TypeEnum 98 | { 99 | ArmorModActivity, 100 | ArmorModGeneral, 101 | ArmorPerkExotic, 102 | SubclassClass, 103 | SubclassFragment, 104 | SubclassGrenade, 105 | SubclassMelee, 106 | SubclassMovement, 107 | SubclassSuper, 108 | TypeWeaponCatalystExotic, 109 | TypeWeaponFrame, 110 | TypeWeaponFrameExotic, 111 | TypeWeaponMod, 112 | TypeWeaponPerkEnhanced, 113 | WeaponCatalystExotic, 114 | WeaponFrame, 115 | WeaponFrameExotic, 116 | WeaponMod, 117 | WeaponOriginTrait, 118 | WeaponPerk, 119 | WeaponPerkEnhanced, 120 | WeaponPerkExotic 121 | } 122 | 123 | public static class ClarityParser 124 | { 125 | private static Dictionary? _clarityDb; 126 | 127 | public static string ToJson(this Dictionary self) 128 | { 129 | return JsonConvert.SerializeObject(self, Converter.Settings); 130 | } 131 | 132 | public static async Task?> Fetch() 133 | { 134 | if (_clarityDb != null) 135 | return _clarityDb; 136 | 137 | using var httpClient = new HttpClient(); 138 | var json = await httpClient.GetStringAsync( 139 | "https://raw.githubusercontent.com/Database-Clarity/Live-Clarity-Database/live/descriptions/crayon.json"); 140 | 141 | _clarityDb = JsonConvert.DeserializeObject>(json, Converter.Settings); 142 | 143 | return _clarityDb; 144 | } 145 | 146 | public static string ClarityClean(this string inputString) 147 | { 148 | var outputString = inputString 149 | .Replace("export description (\n", "") 150 | .Replace("[this sheet ](https://d2clarity.page.link/combatantundefined", 151 | "[this sheet](https://d2clarity.page.link/combatant)") 152 | .Replace("->1", "-> 1") 153 | .Replace("\r", "") 154 | .Replace("**", "") 155 | .Replace("<:primary:968793055677251604>", EmoteHelper.StaticEmote("primary")) 156 | .Replace("<:special:968793055631114330>", EmoteHelper.StaticEmote("special")) 157 | .Replace("<:heavy:968793055652106320>", EmoteHelper.StaticEmote("heavy")) 158 | .Replace("<:stasis:915198000727461909>", EmoteHelper.StaticEmote("stasis")) 159 | .Replace("<:arc:720178925317128243>", EmoteHelper.StaticEmote("arc")) 160 | .Replace("<:solar:720178909361995786>", EmoteHelper.StaticEmote("solar")) 161 | .Replace("<:void:720178940240461864>", EmoteHelper.StaticEmote("void")) 162 | .Replace("<:pve:922884406073507930>", EmoteHelper.StaticEmote("pve")) 163 | .Replace("<:pvp:922884468275019856>", EmoteHelper.StaticEmote("pvp")); 164 | 165 | return outputString; 166 | } 167 | } 168 | 169 | internal static class Converter 170 | { 171 | public static readonly JsonSerializerSettings? Settings = new() 172 | { 173 | MetadataPropertyHandling = MetadataPropertyHandling.Ignore, 174 | DateParseHandling = DateParseHandling.None, 175 | Converters = 176 | { 177 | TypeEnumConverter.Singleton, 178 | new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal } 179 | } 180 | }; 181 | } 182 | 183 | internal class TypeEnumConverter : JsonConverter 184 | { 185 | public static readonly TypeEnumConverter Singleton = new(); 186 | 187 | public override bool CanConvert(Type t) 188 | { 189 | return t == typeof(TypeEnum) || t == typeof(TypeEnum?); 190 | } 191 | 192 | public override object? ReadJson(JsonReader reader, Type t, object? existingValue, JsonSerializer serializer) 193 | { 194 | if (reader.TokenType == JsonToken.Null) 195 | return null; 196 | 197 | var value = serializer.Deserialize(reader); 198 | return value switch 199 | { 200 | "Armor Mod Activity" => TypeEnum.ArmorModActivity, 201 | "Armor Mod General" => TypeEnum.ArmorModGeneral, 202 | "Armor Perk Exotic" => TypeEnum.ArmorPerkExotic, 203 | "Subclass Class" => TypeEnum.SubclassClass, 204 | "Subclass Fragment" => TypeEnum.SubclassFragment, 205 | "Subclass Grenade" => TypeEnum.SubclassGrenade, 206 | "Subclass Melee" => TypeEnum.SubclassMelee, 207 | "Subclass Movement" => TypeEnum.SubclassMovement, 208 | "Subclass Super" => TypeEnum.SubclassSuper, 209 | "Weapon Catalyst Exotic" => TypeEnum.WeaponCatalystExotic, 210 | "Weapon Frame" => TypeEnum.WeaponFrame, 211 | "Weapon Frame Exotic" => TypeEnum.WeaponFrameExotic, 212 | "Weapon Mod" => TypeEnum.WeaponMod, 213 | "Weapon Origin Trait" => TypeEnum.WeaponOriginTrait, 214 | "Weapon Perk" => TypeEnum.WeaponPerk, 215 | "Weapon Perk Enhanced" => TypeEnum.WeaponPerkEnhanced, 216 | "Weapon Perk Exotic" => TypeEnum.WeaponPerkExotic, 217 | "weaponCatalystExotic" => TypeEnum.TypeWeaponCatalystExotic, 218 | "weaponFrame" => TypeEnum.TypeWeaponFrame, 219 | "weaponFrameExotic" => TypeEnum.TypeWeaponFrameExotic, 220 | "weaponMod" => TypeEnum.TypeWeaponMod, 221 | "weaponPerkEnhanced" => TypeEnum.TypeWeaponPerkEnhanced, 222 | _ => throw new Exception("Cannot un-marshal type TypeEnum") 223 | }; 224 | } 225 | 226 | public override void WriteJson(JsonWriter writer, object? untypedValue, JsonSerializer serializer) 227 | { 228 | if (untypedValue == null) 229 | { 230 | serializer.Serialize(writer, null); 231 | return; 232 | } 233 | 234 | var value = (TypeEnum)untypedValue; 235 | switch (value) 236 | { 237 | case TypeEnum.ArmorModActivity: 238 | serializer.Serialize(writer, "Armor Mod Activity"); 239 | return; 240 | case TypeEnum.ArmorModGeneral: 241 | serializer.Serialize(writer, "Armor Mod General"); 242 | return; 243 | case TypeEnum.ArmorPerkExotic: 244 | serializer.Serialize(writer, "Armor Perk Exotic"); 245 | return; 246 | case TypeEnum.SubclassClass: 247 | serializer.Serialize(writer, "Subclass Class"); 248 | return; 249 | case TypeEnum.SubclassFragment: 250 | serializer.Serialize(writer, "Subclass Fragment"); 251 | return; 252 | case TypeEnum.SubclassGrenade: 253 | serializer.Serialize(writer, "Subclass Grenade"); 254 | return; 255 | case TypeEnum.SubclassMelee: 256 | serializer.Serialize(writer, "Subclass Melee"); 257 | return; 258 | case TypeEnum.SubclassMovement: 259 | serializer.Serialize(writer, "Subclass Movement"); 260 | return; 261 | case TypeEnum.SubclassSuper: 262 | serializer.Serialize(writer, "Subclass Super"); 263 | return; 264 | case TypeEnum.WeaponCatalystExotic: 265 | serializer.Serialize(writer, "Weapon Catalyst Exotic"); 266 | return; 267 | case TypeEnum.WeaponFrame: 268 | serializer.Serialize(writer, "Weapon Frame"); 269 | return; 270 | case TypeEnum.WeaponFrameExotic: 271 | serializer.Serialize(writer, "Weapon Frame Exotic"); 272 | return; 273 | case TypeEnum.WeaponMod: 274 | serializer.Serialize(writer, "Weapon Mod"); 275 | return; 276 | case TypeEnum.WeaponOriginTrait: 277 | serializer.Serialize(writer, "Weapon Origin Trait"); 278 | return; 279 | case TypeEnum.WeaponPerk: 280 | serializer.Serialize(writer, "Weapon Perk"); 281 | return; 282 | case TypeEnum.WeaponPerkEnhanced: 283 | serializer.Serialize(writer, "Weapon Perk Enhanced"); 284 | return; 285 | case TypeEnum.WeaponPerkExotic: 286 | serializer.Serialize(writer, "Weapon Perk Exotic"); 287 | return; 288 | case TypeEnum.TypeWeaponCatalystExotic: 289 | serializer.Serialize(writer, "weaponCatalystExotic"); 290 | return; 291 | case TypeEnum.TypeWeaponFrame: 292 | serializer.Serialize(writer, "weaponFrame"); 293 | return; 294 | case TypeEnum.TypeWeaponFrameExotic: 295 | serializer.Serialize(writer, "weaponFrameExotic"); 296 | return; 297 | case TypeEnum.TypeWeaponMod: 298 | serializer.Serialize(writer, "weaponMod"); 299 | return; 300 | case TypeEnum.TypeWeaponPerkEnhanced: 301 | serializer.Serialize(writer, "weaponPerkEnhanced"); 302 | return; 303 | default: 304 | throw new Exception("Cannot marshal type TypeEnum"); 305 | } 306 | } 307 | } 308 | --------------------------------------------------------------------------------