├── .editorconfig ├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ └── build.yml ├── .gitignore ├── Discord Stream Notify Bot.sln ├── Discord Stream Notify Bot ├── Auth │ ├── TokenCrypto.cs │ └── TokenManager.cs ├── Bot.cs ├── BotConfig.cs ├── Command │ ├── Admin │ │ ├── AdministraitonService.cs │ │ └── Administration.cs │ ├── Attribute │ │ ├── CommandExampleAttribute.cs │ │ ├── RequireGuildMemberCountAttribute.cs │ │ └── RequireGuildOwnerAttribute.cs │ ├── CommandHandler.cs │ ├── CommonEqualityComparer.cs │ ├── Extensions.cs │ ├── Help │ │ ├── Help.cs │ │ └── HelpService.cs │ ├── ICommandService.cs │ ├── Normal │ │ └── Normal.cs │ ├── ReactionEventWrapper.cs │ ├── TopLevelModule.cs │ ├── TwitCasting │ │ └── TwitCasting.cs │ ├── Twitch │ │ └── Twitch.cs │ ├── Twitter │ │ └── TwitterSpaces.cs │ ├── Youtube │ │ ├── YoutubeChannelSpider.cs │ │ └── YoutubeStream.cs │ └── YoutubeMember │ │ └── YoutubeMember.cs ├── Data │ └── HelpDescription.txt ├── DataBase │ ├── MainDbContext.cs │ ├── MainDbService.cs │ └── Table │ │ ├── BannerChange.cs │ │ ├── DbEntity.cs │ │ ├── GuildConfig.cs │ │ ├── GuildYoutubeMemberConfig.cs │ │ ├── HoloVideo.cs │ │ ├── NijisanjiVideo.cs │ │ ├── NonApprovedVideo.cs │ │ ├── NoticeTwitcastingStreamChannel.cs │ │ ├── NoticeTwitchStreamChannel.cs │ │ ├── NoticeTwitterSpaceChannel.cs │ │ ├── NoticeYoutubeStreamChannel.cs │ │ ├── OtherVideo.cs │ │ ├── RecordYoutubeChannel.cs │ │ ├── TwitcastingSpider.cs │ │ ├── TwitcastingStream.cs │ │ ├── TwitchSpider.cs │ │ ├── TwitchStream.cs │ │ ├── TwitterSpace.cs │ │ ├── TwitterSpaecSpider.cs │ │ ├── Video.cs │ │ ├── YoutubeChannelNameToId.cs │ │ ├── YoutubeChannelOwnedType.cs │ │ ├── YoutubeChannelSpider.cs │ │ ├── YoutubeMemberAccessToken.cs │ │ └── YoutubeMemberCheck.cs ├── Discord Stream Notify Bot.csproj ├── HttpClients │ ├── DiscordWebhookClient.cs │ ├── TwitCasting │ │ └── TwitcastingClient.cs │ ├── Twitcasting │ │ └── Model │ │ │ ├── CategoriesJson.cs │ │ │ ├── GetAllRegistedWebHookJson.cs │ │ │ ├── GetMovieInfoResponse.cs │ │ │ ├── GetUserInfoResponse.cs │ │ │ └── TcBackendStreamData.cs │ └── Twitter │ │ ├── TwitterClient.cs │ │ ├── TwitterSpacesData.cs │ │ └── TwitterUserJson.cs ├── Interaction │ ├── Attribute │ │ ├── CommandExampleAttribute.cs │ │ ├── CommandSummaryAttribute.cs │ │ ├── RequireGuildAttribute.cs │ │ ├── RequireGuildMemberCountAttribute.cs │ │ └── RequireGuildOwnerAttribute.cs │ ├── CommonEqualityComparer.cs │ ├── Extensions.cs │ ├── Help │ │ ├── Help.cs │ │ └── Service │ │ │ └── HelpService.cs │ ├── IInteractionService.cs │ ├── InteractionHandler.cs │ ├── OwnerOnly │ │ ├── SendMsgToAllGuild.cs │ │ └── Service │ │ │ └── SendMsgToAllGuildService.cs │ ├── ReactionEventWrapper.cs │ ├── TopLevelModule.cs │ ├── Twitcasting │ │ ├── Twitcasting.cs │ │ └── TwitcastingSpider.cs │ ├── Twitch │ │ ├── Twitch.cs │ │ └── TwitchSpider.cs │ ├── Twitter │ │ ├── TwitterSpaces.cs │ │ └── TwitterSpacesSpider.cs │ ├── Utility │ │ ├── Service │ │ │ └── UtilityService.cs │ │ └── Utility.cs │ ├── Youtube │ │ ├── Youtube.cs │ │ └── YoutubeChannelSpider.cs │ └── YoutubeMember │ │ ├── YoutubeMember.cs │ │ └── YoutubeMemberSetting.cs ├── Log.cs ├── Logo.png ├── Logo_128.ico ├── Logo_64.ico ├── Migrations │ ├── 20250320095452_RefactorDbContext.Designer.cs │ ├── 20250320095452_RefactorDbContext.cs │ ├── 20250603065853_ModifyTwitCastingTable.Designer.cs │ ├── 20250603065853_ModifyTwitCastingTable.cs │ └── MainDbContextModelSnapshot.cs ├── Program.cs ├── RedisConnection.cs ├── RedisDataStore.cs ├── SharedService │ ├── EmojiService.cs │ ├── TwitCasting │ │ └── TwitCastingService.cs │ ├── Twitcasting │ │ └── TwitCastingWebHookJson.cs │ ├── Twitch │ │ ├── Debounce │ │ │ └── DebounceChannelUpdateMessage.cs │ │ └── TwitchService.cs │ ├── Twitter │ │ └── TwitterSpacesService.cs │ ├── Youtube │ │ ├── ChangeGuildBanner.cs │ │ ├── Json │ │ │ ├── NijisanjiLiverJson.cs │ │ │ └── NijisanjiStreamJson.cs │ │ ├── ReminderAction.cs │ │ ├── Schedule.cs │ │ └── YoutubeStreamService.cs │ └── YoutubeMember │ │ ├── CheckMemberShip.cs │ │ ├── CheckMemberShipOnlyVideoId.cs │ │ └── YoutubeMemberService.cs ├── UptimeKumaClient.cs └── Utility.cs ├── LICENSE.txt └── README.md /.editorconfig: -------------------------------------------------------------------------------- 1 | [*.cs] 2 | 3 | # Default severity for analyzer diagnostics with category 'Style' 4 | dotnet_analyzer_diagnostic.category-Style.severity = silent 5 | 6 | # CA1050: 在命名空間中宣告類型 7 | dotnet_diagnostic.CA1050.severity = none 8 | csharp_space_around_binary_operators = before_and_after 9 | csharp_indent_labels = one_less_than_current 10 | csharp_using_directive_placement = outside_namespace:silent 11 | csharp_style_conditional_delegate_call = true:suggestion 12 | csharp_style_var_for_built_in_types = false:silent 13 | csharp_style_var_when_type_is_apparent = false:silent 14 | csharp_style_var_elsewhere = false:silent 15 | csharp_prefer_static_local_function = true:suggestion 16 | csharp_prefer_static_anonymous_function = true:suggestion 17 | csharp_style_prefer_readonly_struct = true:suggestion 18 | csharp_style_prefer_readonly_struct_member = true:suggestion 19 | csharp_style_allow_embedded_statements_on_same_line_experimental = true:silent 20 | csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true:silent 21 | csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true:silent 22 | csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true:silent 23 | csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true:silent 24 | 25 | [*.{cs,vb}] 26 | end_of_line = crlf 27 | tab_width = 4 28 | indent_size = 4 29 | dotnet_style_operator_placement_when_wrapping = beginning_of_line 30 | dotnet_style_qualification_for_field = false:silent 31 | dotnet_style_qualification_for_property = false:silent 32 | dotnet_style_qualification_for_method = false:silent 33 | dotnet_style_qualification_for_event = false:silent 34 | dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent 35 | dotnet_code_quality_unused_parameters = all:suggestion 36 | dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent 37 | dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent 38 | dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent 39 | dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent 40 | dotnet_style_allow_multiple_blank_lines_experimental = true:silent 41 | dotnet_style_allow_statement_immediately_after_block_experimental = true:silent 42 | dotnet_style_readonly_field = true:suggestion 43 | dotnet_style_predefined_type_for_locals_parameters_members = true:silent 44 | dotnet_style_predefined_type_for_member_access = true:silent 45 | dotnet_style_coalesce_expression = true:suggestion 46 | dotnet_style_null_propagation = true:suggestion 47 | dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion 48 | dotnet_style_prefer_auto_properties = true:silent 49 | dotnet_style_object_initializer = true:suggestion 50 | dotnet_style_collection_initializer = true:suggestion 51 | dotnet_style_prefer_simplified_boolean_expressions = true:suggestion 52 | dotnet_style_prefer_conditional_expression_over_assignment = true:silent 53 | dotnet_style_prefer_conditional_expression_over_return = true:silent 54 | dotnet_style_explicit_tuple_names = true:suggestion 55 | dotnet_style_prefer_inferred_tuple_names = true:suggestion 56 | dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion 57 | dotnet_style_prefer_compound_assignment = true:suggestion 58 | dotnet_style_prefer_simplified_interpolation = true:suggestion 59 | dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion 60 | dotnet_style_namespace_match_folder = true:suggestion 61 | [*.cs] 62 | #### 命名樣式 #### 63 | 64 | # 命名規則 65 | 66 | dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion 67 | dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface 68 | dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i 69 | 70 | dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion 71 | dotnet_naming_rule.types_should_be_pascal_case.symbols = types 72 | dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case 73 | 74 | dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion 75 | dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members 76 | dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case 77 | 78 | # 符號規格 79 | 80 | dotnet_naming_symbols.interface.applicable_kinds = interface 81 | dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 82 | dotnet_naming_symbols.interface.required_modifiers = 83 | 84 | dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum 85 | dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 86 | dotnet_naming_symbols.types.required_modifiers = 87 | 88 | dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method 89 | dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected 90 | dotnet_naming_symbols.non_field_members.required_modifiers = 91 | 92 | # 命名樣式 93 | 94 | dotnet_naming_style.begins_with_i.required_prefix = I 95 | dotnet_naming_style.begins_with_i.required_suffix = 96 | dotnet_naming_style.begins_with_i.word_separator = 97 | dotnet_naming_style.begins_with_i.capitalization = pascal_case 98 | 99 | dotnet_naming_style.pascal_case.required_prefix = 100 | dotnet_naming_style.pascal_case.required_suffix = 101 | dotnet_naming_style.pascal_case.word_separator = 102 | dotnet_naming_style.pascal_case.capitalization = pascal_case 103 | 104 | dotnet_naming_style.pascal_case.required_prefix = 105 | dotnet_naming_style.pascal_case.required_suffix = 106 | dotnet_naming_style.pascal_case.word_separator = 107 | dotnet_naming_style.pascal_case.capitalization = pascal_case 108 | csharp_style_prefer_switch_expression = true:suggestion 109 | csharp_style_prefer_pattern_matching = true:silent 110 | csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion 111 | csharp_style_pattern_matching_over_as_with_null_check = true:suggestion 112 | csharp_style_prefer_not_pattern = true:suggestion 113 | csharp_style_prefer_extended_property_pattern = true:suggestion 114 | csharp_prefer_simple_using_statement = true:suggestion 115 | csharp_prefer_braces = true:silent 116 | csharp_style_namespace_declarations = block_scoped:silent 117 | csharp_style_prefer_method_group_conversion = true:silent 118 | csharp_style_prefer_top_level_statements = true:silent 119 | csharp_style_prefer_primary_constructors = true:suggestion 120 | csharp_prefer_system_threading_lock = true:suggestion 121 | csharp_style_expression_bodied_methods = false:silent 122 | csharp_style_expression_bodied_constructors = false:silent 123 | csharp_style_expression_bodied_operators = false:silent 124 | csharp_style_expression_bodied_properties = true:silent 125 | csharp_style_expression_bodied_indexers = true:silent 126 | csharp_style_expression_bodied_accessors = true:silent 127 | csharp_style_expression_bodied_lambdas = true:silent 128 | csharp_style_expression_bodied_local_functions = false:silent 129 | csharp_style_throw_expression = true:suggestion 130 | csharp_style_prefer_null_check_over_type_check = true:suggestion 131 | csharp_prefer_simple_default_expression = true:suggestion 132 | csharp_style_prefer_local_over_anonymous_function = true:suggestion 133 | csharp_style_prefer_index_operator = true:suggestion 134 | csharp_style_prefer_range_operator = true:suggestion 135 | csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion 136 | csharp_style_prefer_tuple_swap = true:suggestion 137 | csharp_style_prefer_unbound_generic_type_in_nameof = true:suggestion 138 | csharp_style_prefer_utf8_string_literals = true:suggestion 139 | csharp_style_inlined_variable_declaration = true:suggestion 140 | csharp_style_deconstructed_variable_declaration = true:suggestion 141 | csharp_style_unused_value_assignment_preference = discard_variable:suggestion 142 | csharp_style_unused_value_expression_statement_preference = discard_variable:silent 143 | 144 | [*.vb] 145 | #### 命名樣式 #### 146 | 147 | # 命名規則 148 | 149 | dotnet_naming_rule.interface_should_be_以_i_開頭.severity = suggestion 150 | dotnet_naming_rule.interface_should_be_以_i_開頭.symbols = interface 151 | dotnet_naming_rule.interface_should_be_以_i_開頭.style = 以_i_開頭 152 | 153 | dotnet_naming_rule.型別_should_be_pascal_命名法的大小寫.severity = suggestion 154 | dotnet_naming_rule.型別_should_be_pascal_命名法的大小寫.symbols = 型別 155 | dotnet_naming_rule.型別_should_be_pascal_命名法的大小寫.style = pascal_命名法的大小寫 156 | 157 | dotnet_naming_rule.非欄位成員_should_be_pascal_命名法的大小寫.severity = suggestion 158 | dotnet_naming_rule.非欄位成員_should_be_pascal_命名法的大小寫.symbols = 非欄位成員 159 | dotnet_naming_rule.非欄位成員_should_be_pascal_命名法的大小寫.style = pascal_命名法的大小寫 160 | 161 | # 符號規格 162 | 163 | dotnet_naming_symbols.interface.applicable_kinds = interface 164 | dotnet_naming_symbols.interface.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected 165 | dotnet_naming_symbols.interface.required_modifiers = 166 | 167 | dotnet_naming_symbols.型別.applicable_kinds = class, struct, interface, enum 168 | dotnet_naming_symbols.型別.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected 169 | dotnet_naming_symbols.型別.required_modifiers = 170 | 171 | dotnet_naming_symbols.非欄位成員.applicable_kinds = property, event, method 172 | dotnet_naming_symbols.非欄位成員.applicable_accessibilities = public, friend, private, protected, protected_friend, private_protected 173 | dotnet_naming_symbols.非欄位成員.required_modifiers = 174 | 175 | # 命名樣式 176 | 177 | dotnet_naming_style.以_i_開頭.required_prefix = I 178 | dotnet_naming_style.以_i_開頭.required_suffix = 179 | dotnet_naming_style.以_i_開頭.word_separator = 180 | dotnet_naming_style.以_i_開頭.capitalization = pascal_case 181 | 182 | dotnet_naming_style.pascal_命名法的大小寫.required_prefix = 183 | dotnet_naming_style.pascal_命名法的大小寫.required_suffix = 184 | dotnet_naming_style.pascal_命名法的大小寫.word_separator = 185 | dotnet_naming_style.pascal_命名法的大小寫.capitalization = pascal_case 186 | 187 | dotnet_naming_style.pascal_命名法的大小寫.required_prefix = 188 | dotnet_naming_style.pascal_命名法的大小寫.required_suffix = 189 | dotnet_naming_style.pascal_命名法的大小寫.word_separator = 190 | dotnet_naming_style.pascal_命名法的大小寫.capitalization = pascal_case 191 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: 'konnokai' 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | 8 | 9 | jobs: 10 | build: 11 | name: Build 12 | runs-on: windows-latest 13 | permissions: read-all 14 | steps: 15 | - uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 18 | - name: Set up JDK 17 19 | uses: actions/setup-java@v1 20 | with: 21 | java-version: 17 22 | - name: Cache SonarQube packages 23 | uses: actions/cache@v1 24 | with: 25 | path: ~\.sonar\cache 26 | key: ${{ runner.os }}-sonar 27 | restore-keys: ${{ runner.os }}-sonar 28 | - name: Cache SonarQube scanner 29 | id: cache-sonar-scanner 30 | uses: actions/cache@v1 31 | with: 32 | path: .\.sonar\scanner 33 | key: ${{ runner.os }}-sonar-scanner 34 | restore-keys: ${{ runner.os }}-sonar-scanner 35 | - name: Install SonarQube scanner 36 | if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' 37 | shell: powershell 38 | run: | 39 | New-Item -Path .\.sonar\scanner -ItemType Directory 40 | dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner 41 | - name: Build and analyze 42 | shell: powershell 43 | run: | 44 | .\.sonar\scanner\dotnet-sonarscanner begin /k:"konnokai_Discord-Stream-Notify-Bot_6d6ba123-be32-4ed6-87e9-13e25e542aca" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="${{ secrets.SONAR_HOST_URL }}" 45 | dotnet build 46 | .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | # User-specific files 7 | *.rsuser 8 | *.suo 9 | *.user 10 | *.userosscache 11 | *.sln.docstates 12 | 13 | # User-specific files (MonoDevelop/Xamarin Studio) 14 | *.userprefs 15 | 16 | # Mono auto generated files 17 | mono_crash.* 18 | 19 | # Build results 20 | [Dd]ebug/ 21 | [Dd]ebugPublic/ 22 | [Rr]elease/ 23 | [Rr]eleases/ 24 | x64/ 25 | x86/ 26 | [Ww][Ii][Nn]32/ 27 | [Aa][Rr][Mm]/ 28 | [Aa][Rr][Mm]64/ 29 | bld/ 30 | [Bb]in/ 31 | [Oo]bj/ 32 | [Oo]ut/ 33 | [Ll]og/ 34 | [Ll]ogs/ 35 | 36 | # Visual Studio 2015/2017 cache/options directory 37 | .vs/ 38 | # Uncomment if you have tasks that create the project's static files in wwwroot 39 | #wwwroot/ 40 | 41 | # Visual Studio 2017 auto generated files 42 | Generated\ Files/ 43 | 44 | # MSTest test Results 45 | [Tt]est[Rr]esult*/ 46 | [Bb]uild[Ll]og.* 47 | 48 | # NUnit 49 | *.VisualState.xml 50 | TestResult.xml 51 | nunit-*.xml 52 | 53 | # Build Results of an ATL Project 54 | [Dd]ebugPS/ 55 | [Rr]eleasePS/ 56 | dlldata.c 57 | 58 | # Benchmark Results 59 | BenchmarkDotNet.Artifacts/ 60 | 61 | # .NET Core 62 | project.lock.json 63 | project.fragment.lock.json 64 | artifacts/ 65 | 66 | # ASP.NET Scaffolding 67 | ScaffoldingReadMe.txt 68 | 69 | # StyleCop 70 | StyleCopReport.xml 71 | 72 | # Files built by Visual Studio 73 | *_i.c 74 | *_p.c 75 | *_h.h 76 | *.ilk 77 | *.meta 78 | *.obj 79 | *.iobj 80 | *.pch 81 | *.pdb 82 | *.ipdb 83 | *.pgc 84 | *.pgd 85 | *.rsp 86 | *.sbr 87 | *.tlb 88 | *.tli 89 | *.tlh 90 | *.tmp 91 | *.tmp_proj 92 | *_wpftmp.csproj 93 | *.log 94 | *.vspscc 95 | *.vssscc 96 | .builds 97 | *.pidb 98 | *.svclog 99 | *.scc 100 | 101 | # Chutzpah Test files 102 | _Chutzpah* 103 | 104 | # Visual C++ cache files 105 | ipch/ 106 | *.aps 107 | *.ncb 108 | *.opendb 109 | *.opensdf 110 | *.sdf 111 | *.cachefile 112 | *.VC.db 113 | *.VC.VC.opendb 114 | 115 | # Visual Studio profiler 116 | *.psess 117 | *.vsp 118 | *.vspx 119 | *.sap 120 | 121 | # Visual Studio Trace Files 122 | *.e2e 123 | 124 | # TFS 2012 Local Workspace 125 | $tf/ 126 | 127 | # Guidance Automation Toolkit 128 | *.gpState 129 | 130 | # ReSharper is a .NET coding add-in 131 | _ReSharper*/ 132 | *.[Rr]e[Ss]harper 133 | *.DotSettings.user 134 | 135 | # TeamCity is a build add-in 136 | _TeamCity* 137 | 138 | # DotCover is a Code Coverage Tool 139 | *.dotCover 140 | 141 | # AxoCover is a Code Coverage Tool 142 | .axoCover/* 143 | !.axoCover/settings.json 144 | 145 | # Coverlet is a free, cross platform Code Coverage Tool 146 | coverage*.json 147 | coverage*.xml 148 | coverage*.info 149 | 150 | # Visual Studio code coverage results 151 | *.coverage 152 | *.coveragexml 153 | 154 | # NCrunch 155 | _NCrunch_* 156 | .*crunch*.local.xml 157 | nCrunchTemp_* 158 | 159 | # MightyMoose 160 | *.mm.* 161 | AutoTest.Net/ 162 | 163 | # Web workbench (sass) 164 | .sass-cache/ 165 | 166 | # Installshield output folder 167 | [Ee]xpress/ 168 | 169 | # DocProject is a documentation generator add-in 170 | DocProject/buildhelp/ 171 | DocProject/Help/*.HxT 172 | DocProject/Help/*.HxC 173 | DocProject/Help/*.hhc 174 | DocProject/Help/*.hhk 175 | DocProject/Help/*.hhp 176 | DocProject/Help/Html2 177 | DocProject/Help/html 178 | 179 | # Click-Once directory 180 | publish/ 181 | 182 | # Publish Web Output 183 | *.[Pp]ublish.xml 184 | *.azurePubxml 185 | # Note: Comment the next line if you want to checkin your web deploy settings, 186 | # but database connection strings (with potential passwords) will be unencrypted 187 | *.pubxml 188 | *.publishproj 189 | 190 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 191 | # checkin your Azure Web App publish settings, but sensitive information contained 192 | # in these scripts will be unencrypted 193 | PublishScripts/ 194 | 195 | # NuGet Packages 196 | *.nupkg 197 | # NuGet Symbol Packages 198 | *.snupkg 199 | # The packages folder can be ignored because of Package Restore 200 | **/[Pp]ackages/* 201 | # except build/, which is used as an MSBuild target. 202 | !**/[Pp]ackages/build/ 203 | # Uncomment if necessary however generally it will be regenerated when needed 204 | #!**/[Pp]ackages/repositories.config 205 | # NuGet v3's project.json files produces more ignorable files 206 | *.nuget.props 207 | *.nuget.targets 208 | 209 | # Microsoft Azure Build Output 210 | csx/ 211 | *.build.csdef 212 | 213 | # Microsoft Azure Emulator 214 | ecf/ 215 | rcf/ 216 | 217 | # Windows Store app package directories and files 218 | AppPackages/ 219 | BundleArtifacts/ 220 | Package.StoreAssociation.xml 221 | _pkginfo.txt 222 | *.appx 223 | *.appxbundle 224 | *.appxupload 225 | 226 | # Visual Studio cache files 227 | # files ending in .cache can be ignored 228 | *.[Cc]ache 229 | # but keep track of directories ending in .cache 230 | !?*.[Cc]ache/ 231 | 232 | # Others 233 | ClientBin/ 234 | ~$* 235 | *~ 236 | *.dbmdl 237 | *.dbproj.schemaview 238 | *.jfm 239 | *.pfx 240 | *.publishsettings 241 | orleans.codegen.cs 242 | 243 | # Including strong name files can present a security risk 244 | # (https://github.com/github/gitignore/pull/2483#issue-259490424) 245 | #*.snk 246 | 247 | # Since there are multiple workflows, uncomment next line to ignore bower_components 248 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 249 | #bower_components/ 250 | 251 | # RIA/Silverlight projects 252 | Generated_Code/ 253 | 254 | # Backup & report files from converting an old project file 255 | # to a newer Visual Studio version. Backup files are not needed, 256 | # because we have git ;-) 257 | _UpgradeReport_Files/ 258 | Backup*/ 259 | UpgradeLog*.XML 260 | UpgradeLog*.htm 261 | ServiceFabricBackup/ 262 | *.rptproj.bak 263 | 264 | # SQL Server files 265 | *.mdf 266 | *.ldf 267 | *.ndf 268 | 269 | # Business Intelligence projects 270 | *.rdl.data 271 | *.bim.layout 272 | *.bim_*.settings 273 | *.rptproj.rsuser 274 | *- [Bb]ackup.rdl 275 | *- [Bb]ackup ([0-9]).rdl 276 | *- [Bb]ackup ([0-9][0-9]).rdl 277 | 278 | # Microsoft Fakes 279 | FakesAssemblies/ 280 | 281 | # GhostDoc plugin setting file 282 | *.GhostDoc.xml 283 | 284 | # Node.js Tools for Visual Studio 285 | .ntvs_analysis.dat 286 | node_modules/ 287 | 288 | # Visual Studio 6 build log 289 | *.plg 290 | 291 | # Visual Studio 6 workspace options file 292 | *.opt 293 | 294 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 295 | *.vbw 296 | 297 | # Visual Studio LightSwitch build output 298 | **/*.HTMLClient/GeneratedArtifacts 299 | **/*.DesktopClient/GeneratedArtifacts 300 | **/*.DesktopClient/ModelManifest.xml 301 | **/*.Server/GeneratedArtifacts 302 | **/*.Server/ModelManifest.xml 303 | _Pvt_Extensions 304 | 305 | # Paket dependency manager 306 | .paket/paket.exe 307 | paket-files/ 308 | 309 | # FAKE - F# Make 310 | .fake/ 311 | 312 | # CodeRush personal settings 313 | .cr/personal 314 | 315 | # Python Tools for Visual Studio (PTVS) 316 | __pycache__/ 317 | *.pyc 318 | 319 | # Cake - Uncomment if you are using it 320 | # tools/** 321 | # !tools/packages.config 322 | 323 | # Tabs Studio 324 | *.tss 325 | 326 | # Telerik's JustMock configuration file 327 | *.jmconfig 328 | 329 | # BizTalk build output 330 | *.btp.cs 331 | *.btm.cs 332 | *.odx.cs 333 | *.xsd.cs 334 | 335 | # OpenCover UI analysis results 336 | OpenCover/ 337 | 338 | # Azure Stream Analytics local run output 339 | ASALocalRun/ 340 | 341 | # MSBuild Binary and Structured Log 342 | *.binlog 343 | 344 | # NVidia Nsight GPU debugger configuration file 345 | *.nvuser 346 | 347 | # MFractors (Xamarin productivity tool) working folder 348 | .mfractor/ 349 | 350 | # Local History for Visual Studio 351 | .localhistory/ 352 | 353 | # BeatPulse healthcheck temp database 354 | healthchecksdb 355 | 356 | # Backup folder for Package Reference Convert tool in Visual Studio 2017 357 | MigrationBackup/ 358 | 359 | # Ionide (cross platform F# VS Code tools) working folder 360 | .ionide/ 361 | 362 | # Fody - auto-generated XML schema 363 | FodyWeavers.xsd 364 | 365 | /Discord Stream Notify Bot/DbTrackerLog.txt -------------------------------------------------------------------------------- /Discord Stream Notify Bot.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.2.32526.322 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Discord Stream Notify Bot", "Discord Stream Notify Bot\Discord Stream Notify Bot.csproj", "{A3022865-83A3-4A54-AB3D-E4589F705146}" 7 | EndProject 8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{C7EE682C-42B5-40F4-B959-F717BBBF72A8}" 9 | ProjectSection(SolutionItems) = preProject 10 | .editorconfig = .editorconfig 11 | LICENSE.txt = LICENSE.txt 12 | README.md = README.md 13 | EndProjectSection 14 | EndProject 15 | Global 16 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 17 | Debug_API|Any CPU = Debug_API|Any CPU 18 | Debug_DontRegisterCommand|Any CPU = Debug_DontRegisterCommand|Any CPU 19 | Debug|Any CPU = Debug|Any CPU 20 | Release|Any CPU = Release|Any CPU 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {A3022865-83A3-4A54-AB3D-E4589F705146}.Debug_API|Any CPU.ActiveCfg = Debug_API|Any CPU 24 | {A3022865-83A3-4A54-AB3D-E4589F705146}.Debug_API|Any CPU.Build.0 = Debug_API|Any CPU 25 | {A3022865-83A3-4A54-AB3D-E4589F705146}.Debug_DontRegisterCommand|Any CPU.ActiveCfg = Debug_DontRegisterCommand|Any CPU 26 | {A3022865-83A3-4A54-AB3D-E4589F705146}.Debug_DontRegisterCommand|Any CPU.Build.0 = Debug_DontRegisterCommand|Any CPU 27 | {A3022865-83A3-4A54-AB3D-E4589F705146}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {A3022865-83A3-4A54-AB3D-E4589F705146}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {A3022865-83A3-4A54-AB3D-E4589F705146}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {A3022865-83A3-4A54-AB3D-E4589F705146}.Release|Any CPU.Build.0 = Release|Any CPU 31 | EndGlobalSection 32 | GlobalSection(SolutionProperties) = preSolution 33 | HideSolutionNode = FALSE 34 | EndGlobalSection 35 | GlobalSection(ExtensibilityGlobals) = postSolution 36 | SolutionGuid = {C43BB15A-2E55-458A-AA66-C0B60165014B} 37 | EndGlobalSection 38 | EndGlobal 39 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Auth/TokenCrypto.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | using System.Text; 3 | 4 | namespace Discord_Stream_Notify_Bot.Auth 5 | { 6 | public class TokenCrypto 7 | { 8 | //產生 HMACSHA256 雜湊 9 | public static string ComputeHMACSHA256(string data, string key) 10 | { 11 | var keyBytes = Encoding.UTF8.GetBytes(key); 12 | using (var hmacSHA = new HMACSHA256(keyBytes)) 13 | { 14 | var dataBytes = Encoding.UTF8.GetBytes(data); 15 | var hash = hmacSHA.ComputeHash(dataBytes, 0, dataBytes.Length); 16 | return BitConverter.ToString(hash).Replace("-", "").ToUpper(); 17 | } 18 | } 19 | 20 | //AES 加密 21 | public static string AESEncrypt(string data, string key, string iv) 22 | { 23 | var keyBytes = Encoding.UTF8.GetBytes(key); 24 | var ivBytes = Encoding.UTF8.GetBytes(iv); 25 | var dataBytes = Encoding.UTF8.GetBytes(data); 26 | using (var aes = Aes.Create()) 27 | { 28 | aes.Key = keyBytes; 29 | aes.IV = ivBytes; 30 | aes.Mode = CipherMode.CBC; 31 | aes.Padding = PaddingMode.PKCS7; 32 | var encryptor = aes.CreateEncryptor(); 33 | var encrypt = encryptor 34 | .TransformFinalBlock(dataBytes, 0, dataBytes.Length); 35 | return Convert.ToBase64String(encrypt); 36 | } 37 | } 38 | 39 | //AES 解密 40 | public static string AESDecrypt(string data, string key, string iv) 41 | { 42 | var keyBytes = Encoding.UTF8.GetBytes(key); 43 | var ivBytes = Encoding.UTF8.GetBytes(iv); 44 | var dataBytes = Convert.FromBase64String(data); 45 | using (var aes = Aes.Create()) 46 | { 47 | aes.Key = keyBytes; 48 | aes.IV = ivBytes; 49 | aes.Mode = CipherMode.CBC; 50 | aes.Padding = PaddingMode.PKCS7; 51 | var decryptor = aes.CreateDecryptor(); 52 | var decrypt = decryptor 53 | .TransformFinalBlock(dataBytes, 0, dataBytes.Length); 54 | return Encoding.UTF8.GetString(decrypt); 55 | } 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Auth/TokenManager.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace Discord_Stream_Notify_Bot.Auth 4 | { 5 | public class TokenManager 6 | { 7 | /// 8 | /// 產生加密使用者資料 9 | /// 10 | /// 尚未加密的使用者資料 11 | /// 已加密的使用者資料 12 | public static string CreateToken(object data, string key) 13 | { 14 | var json = JsonConvert.SerializeObject(data); 15 | var base64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(json)); 16 | var iv = Guid.NewGuid().ToString().Replace("-", "").Substring(0, 16); 17 | 18 | //使用 AES 加密 Payload 19 | var encrypt = TokenCrypto 20 | .AESEncrypt(base64, key.Substring(0, 16), iv); 21 | 22 | //取得簽章 23 | var signature = TokenCrypto 24 | .ComputeHMACSHA256(iv + "." + encrypt, key.Substring(0, 64)); 25 | 26 | return iv + "." + encrypt + "." + signature; 27 | } 28 | 29 | /// 30 | /// 解密Google Token Response資料 31 | /// 32 | /// 已加密的Google Token Response資料 33 | /// 未加密的Google Token Response資料 34 | /// Token格式錯誤 35 | /// 簽章驗證失敗 36 | public static T GetTokenResponseValue(string token, string key) 37 | { 38 | if (string.IsNullOrWhiteSpace(token)) return default(T); 39 | 40 | token = token.Replace(" ", "+"); 41 | var split = token.Split('.'); 42 | if (split.Length != 3) throw new ArgumentOutOfRangeException("token"); 43 | 44 | var iv = split[0]; 45 | var encrypt = split[1]; 46 | var signature = split[2]; 47 | 48 | //檢查簽章是否正確 49 | if (signature != TokenCrypto.ComputeHMACSHA256(iv + "." + encrypt, key.Substring(0, 64))) 50 | throw new ArgumentException(signature); 51 | 52 | //使用 AES 解密 Payload 53 | var base64 = TokenCrypto.AESDecrypt(encrypt, key.Substring(0, 16), iv); 54 | var json = Encoding.UTF8.GetString(Convert.FromBase64String(base64)); 55 | var payload = JsonConvert.DeserializeObject(json); 56 | 57 | return payload; 58 | } 59 | } 60 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/BotConfig.cs: -------------------------------------------------------------------------------- 1 | using Discord_Stream_Notify_Bot; 2 | 3 | public class BotConfig 4 | { 5 | public string MySqlConnectionString { get; set; } = "Server=localhost;Port=3306;User Id=stream_bot;Password=Ch@nge_Me;Database=discord_stream_bot"; 6 | public string RedisOption { get; set; } = "127.0.0.1,syncTimeout=3000"; 7 | public string RedisTokenKey { get; set; } = ""; 8 | 9 | public string ApiServerDomain { get; set; } = ""; 10 | public string UptimeKumaPushUrl { get; set; } = ""; 11 | 12 | public string DiscordToken { get; set; } = ""; 13 | public ulong TestSlashCommandGuildId { get; set; } = 0; 14 | public string WebHookUrl { get; set; } = ""; 15 | 16 | public string GoogleApiKey { get; set; } = ""; 17 | public string GoogleClientId { get; set; } = ""; 18 | public string GoogleClientSecret { get; set; } = ""; 19 | 20 | public string TwitCastingClientId { get; set; } = ""; 21 | public string TwitCastingClientSecret { get; set; } = ""; 22 | public string TwitCastingRecordPath { get; set; } = ""; 23 | 24 | // https://streamlink.github.io/cli/plugins/twitch.html#authentication 25 | // 先放著,未來可能會用到 26 | public string TwitchCookieAuthToken { get; set; } = ""; 27 | public string TwitchClientId { get; set; } = ""; 28 | public string TwitchClientSecret { get; set; } = ""; 29 | 30 | public string TwitterAuthToken { get; set; } = ""; 31 | public string TwitterCSRFToken { get; set; } = ""; 32 | public string TwitterSpaceRecordPath { get; set; } = ""; 33 | 34 | public ulong YouTubeEmoteId { get; set; } = 1265158558299848827; 35 | public ulong PayPalEmoteId { get; set; } = 1265158658015236107; 36 | public ulong ECPayEmoteId { get; set; } = 1379272194210795622; 37 | 38 | public void InitBotConfig() 39 | { 40 | try { File.WriteAllText("bot_config_example.json", JsonConvert.SerializeObject(new BotConfig(), Formatting.Indented)); } catch { } 41 | if (!File.Exists("bot_config.json")) 42 | { 43 | Log.Error($"bot_config.json 遺失,請依照 {Path.GetFullPath("bot_config_example.json")} 內的格式填入正確的數值"); 44 | if (!Console.IsInputRedirected) 45 | Console.ReadKey(); 46 | Environment.Exit(3); 47 | } 48 | 49 | var config = JsonConvert.DeserializeObject(File.ReadAllText("bot_config.json")); 50 | 51 | try 52 | { 53 | if (string.IsNullOrWhiteSpace(config.DiscordToken)) 54 | { 55 | Log.Error($"{nameof(DiscordToken)} 遺失,請輸入至 bot_config.json 後重開 Bot"); 56 | if (!Console.IsInputRedirected) 57 | Console.ReadKey(); 58 | Environment.Exit(3); 59 | } 60 | 61 | if (string.IsNullOrWhiteSpace(config.WebHookUrl)) 62 | { 63 | Log.Error($"{nameof(WebHookUrl)} 遺失,請輸入至 bot_config.json 後重開 Bot"); 64 | if (!Console.IsInputRedirected) 65 | Console.ReadKey(); 66 | Environment.Exit(3); 67 | } 68 | 69 | if (string.IsNullOrWhiteSpace(config.GoogleApiKey)) 70 | { 71 | Log.Error($"{nameof(GoogleApiKey)} 遺失,請輸入至 bot_config.json 後重開 Bot"); 72 | if (!Console.IsInputRedirected) 73 | Console.ReadKey(); 74 | Environment.Exit(3); 75 | } 76 | 77 | if (string.IsNullOrWhiteSpace(config.ApiServerDomain)) 78 | { 79 | Log.Error($"{nameof(ApiServerDomain)} 遺失,請輸入至 bot_config.json 後重開 Bot"); 80 | if (!Console.IsInputRedirected) 81 | Console.ReadKey(); 82 | Environment.Exit(3); 83 | } 84 | 85 | MySqlConnectionString = config.MySqlConnectionString; 86 | RedisOption = config.RedisOption; 87 | RedisTokenKey = config.RedisTokenKey; 88 | ApiServerDomain = config.ApiServerDomain; 89 | DiscordToken = config.DiscordToken; 90 | WebHookUrl = config.WebHookUrl; 91 | GoogleApiKey = config.GoogleApiKey; 92 | TestSlashCommandGuildId = config.TestSlashCommandGuildId; 93 | TwitCastingClientId = config.TwitCastingClientId; 94 | TwitCastingClientSecret = config.TwitCastingClientSecret; 95 | TwitCastingRecordPath = config.TwitCastingRecordPath; 96 | TwitchCookieAuthToken = config.TwitchCookieAuthToken; 97 | TwitchClientId = config.TwitchClientId; 98 | TwitchClientSecret = config.TwitchClientSecret; 99 | TwitterAuthToken = config.TwitterAuthToken; 100 | TwitterCSRFToken = config.TwitterCSRFToken; 101 | TwitterSpaceRecordPath = config.TwitterSpaceRecordPath; 102 | GoogleClientId = config.GoogleClientId; 103 | GoogleClientSecret = config.GoogleClientSecret; 104 | UptimeKumaPushUrl = config.UptimeKumaPushUrl; 105 | YouTubeEmoteId = config.YouTubeEmoteId; 106 | PayPalEmoteId = config.PayPalEmoteId; 107 | ECPayEmoteId = config.ECPayEmoteId; 108 | 109 | if (string.IsNullOrWhiteSpace(config.RedisTokenKey) || string.IsNullOrWhiteSpace(RedisTokenKey)) 110 | { 111 | Log.Error($"{nameof(RedisTokenKey)} 遺失,將重新建立隨機亂數"); 112 | 113 | RedisTokenKey = GenRandomKey(); 114 | 115 | try { File.WriteAllText("bot_config.json", JsonConvert.SerializeObject(this, Formatting.Indented)); } 116 | catch (Exception ex) 117 | { 118 | Log.Error($"設定檔保存失敗: {ex}"); 119 | Log.Error($"請手動將此字串填入設定檔中的 \"{nameof(RedisTokenKey)}\" 欄位: {RedisTokenKey}"); 120 | Environment.Exit(3); 121 | } 122 | } 123 | 124 | Utility.RedisKey = RedisTokenKey; 125 | } 126 | catch (Exception ex) 127 | { 128 | Log.Error($"設定檔讀取失敗: {ex}"); 129 | throw; 130 | } 131 | } 132 | 133 | public static string GenRandomKey(int length = 128) 134 | { 135 | var characters = "ABCDEF_GHIJKLMNOPQRSTUVWXYZ@abcdefghijklmnopqrstuvwx-yz0123456789"; 136 | var Charsarr = new char[128]; 137 | var random = new Random(); 138 | 139 | for (int i = 0; i < Charsarr.Length; i++) 140 | { 141 | Charsarr[i] = characters[random.Next(characters.Length)]; 142 | } 143 | 144 | var resultString = new string(Charsarr); 145 | resultString = resultString[Math.Min(length, resultString.Length)..]; 146 | return resultString; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/Admin/AdministraitonService.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Command.Admin 2 | { 3 | public class AdministrationService : ICommandService 4 | { 5 | private string _reloadOfficialGuildListKey = "DiscordStreamBot:Admin:ReloadOfficialGuildList"; 6 | private readonly DiscordSocketClient _Client; 7 | 8 | public AdministrationService(DiscordSocketClient client) 9 | { 10 | _Client = client; 11 | 12 | Bot.RedisSub.Subscribe(new RedisChannel(_reloadOfficialGuildListKey, RedisChannel.PatternMode.Literal), (_, _) => 13 | { 14 | try 15 | { 16 | Utility.OfficialGuildList = JsonConvert.DeserializeObject>(File.ReadAllText(Utility.GetDataFilePath("OfficialList.json"))); 17 | } 18 | catch (Exception ex) 19 | { 20 | Log.Error(ex.Demystify(), "ReloadOfficialGuildList Error"); 21 | } 22 | }); 23 | } 24 | 25 | public async Task ClearUser(ITextChannel textChannel) 26 | { 27 | IEnumerable msgs = (await textChannel.GetMessagesAsync(100).FlattenAsync().ConfigureAwait(false)) 28 | .Where((item) => item.Author.Id == _Client.CurrentUser.Id); 29 | 30 | await Task.WhenAll(Task.Delay(1000), textChannel.DeleteMessagesAsync(msgs)).ConfigureAwait(false); 31 | } 32 | 33 | internal bool WriteAndReloadOfficialListFile() 34 | { 35 | try 36 | { 37 | File.WriteAllText(Utility.GetDataFilePath("OfficialList.json"), JsonConvert.SerializeObject(Utility.OfficialGuildList)); 38 | Bot.RedisSub.Publish(new RedisChannel(_reloadOfficialGuildListKey, RedisChannel.PatternMode.Literal), ""); 39 | } 40 | catch (Exception ex) 41 | { 42 | Log.Error(ex.Demystify(), "WriteOfficialListFile Error"); 43 | return false; 44 | } 45 | 46 | return true; 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/Attribute/CommandExampleAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Command.Attribute 2 | { 3 | [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] 4 | sealed class CommandExampleAttribute : System.Attribute 5 | { 6 | readonly string[] expArray; 7 | 8 | public CommandExampleAttribute(params string[] expArray) 9 | { 10 | this.expArray = expArray; 11 | } 12 | 13 | public string[] ExpArray 14 | { 15 | get { return expArray; } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/Attribute/RequireGuildMemberCountAttribute.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | 3 | namespace Discord_Stream_Notify_Bot.Command.Attribute 4 | { 5 | public class RequireGuildMemberCountAttribute : PreconditionAttribute 6 | { 7 | public RequireGuildMemberCountAttribute(uint gCount) 8 | { 9 | GuildMemberCount = gCount; 10 | } 11 | 12 | public uint? GuildMemberCount { get; } 13 | public override string ErrorMessage { get; set; } = "此伺服器不可使用本指令"; 14 | 15 | public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) 16 | { 17 | if (context.Message.Author.Id == Bot.ApplicatonOwner.Id) return Task.FromResult(PreconditionResult.FromSuccess()); 18 | 19 | if (Utility.OfficialGuildList.Contains(context.Guild.Id)) return Task.FromResult(PreconditionResult.FromSuccess()); 20 | 21 | var memberCount = ((SocketGuild)context.Guild).MemberCount; 22 | if (memberCount >= GuildMemberCount) return Task.FromResult(PreconditionResult.FromSuccess()); 23 | else return Task.FromResult(PreconditionResult.FromError($"此伺服器不可使用本指令\n" + 24 | $"指令要求伺服器人數: `{GuildMemberCount}` 人\n" + 25 | $"目前 Bot 所取得的伺服器人數: `{memberCount}` 人\n" + 26 | $"由於快取的關係,可能會遇到伺服器人數錯誤的問題\n" + 27 | $"如有任何需要請聯繫 Bot 擁有者處理 (你可使用 `/utility send-message-to-bot-owner` 對擁有者發送訊息)")); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/Attribute/RequireGuildOwnerAttribute.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | 3 | namespace Discord_Stream_Notify_Bot.Command.Attribute 4 | { 5 | public class RequireGuildOwnerAttribute : PreconditionAttribute 6 | { 7 | public RequireGuildOwnerAttribute() 8 | { 9 | } 10 | 11 | public override string ErrorMessage { get; set; } = "非伺服器擁有者不可使用本指令"; 12 | 13 | public override Task CheckPermissionsAsync(ICommandContext context, CommandInfo command, IServiceProvider services) 14 | { 15 | if (context.Message.Author.Id == Bot.ApplicatonOwner.Id) return Task.FromResult(PreconditionResult.FromSuccess()); 16 | 17 | if (context.Message.Author.Id == context.Guild.OwnerId) return Task.FromResult(PreconditionResult.FromSuccess()); 18 | else return Task.FromResult(PreconditionResult.FromError(ErrorMessage)); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/CommandHandler.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | using System.Reflection; 3 | 4 | namespace Discord_Stream_Notify_Bot.Command 5 | { 6 | class CommandHandler : ICommandService 7 | { 8 | private readonly DiscordSocketClient _client; 9 | private readonly CommandService _commands; 10 | private readonly IServiceProvider _services; 11 | 12 | public CommandHandler(IServiceProvider services, CommandService commands, DiscordSocketClient client) 13 | { 14 | _commands = commands; 15 | _services = services; 16 | _client = client; 17 | } 18 | 19 | public async Task InitializeAsync() 20 | { 21 | await _commands.AddModulesAsync( 22 | assembly: Assembly.GetEntryAssembly(), 23 | services: _services); 24 | _client.MessageReceived += (msg) => { var _ = Task.Run(() => HandleCommandAsync(msg)); return Task.CompletedTask; }; 25 | } 26 | 27 | private async Task HandleCommandAsync(SocketMessage messageParam) 28 | { 29 | var message = messageParam as SocketUserMessage; 30 | if (message == null || message.Author.IsBot) return; 31 | 32 | int argPos = 0; 33 | if (message.HasStringPrefix("s!", ref argPos)) 34 | { 35 | var context = new SocketCommandContext(_client, message); 36 | 37 | if (_commands.Search(context, argPos).IsSuccess) 38 | { 39 | var result = await _commands.ExecuteAsync( 40 | context: context, 41 | argPos: argPos, 42 | services: _services); 43 | 44 | if (!result.IsSuccess) 45 | { 46 | Log.FormatColorWrite($"[{context.Guild?.Name}/{context.Message.Channel?.Name}] {message.Author.Username} 執行 {context.Message} 發生錯誤", ConsoleColor.Red); 47 | Log.FormatColorWrite(result.ErrorReason, ConsoleColor.Red); 48 | await context.Channel.SendErrorAsync(result.ErrorReason); 49 | } 50 | else 51 | { 52 | try { if (context.Message.Author.Id == Bot.ApplicatonOwner.Id || context.Message.CleanContent == "s!ymlc") await message.DeleteAsync(); } 53 | catch { } 54 | Log.FormatColorWrite($"[{context.Guild?.Name}/{context.Message.Channel?.Name}] {message.Author.Username} 執行 {context.Message}", ConsoleColor.DarkYellow); 55 | } 56 | } 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/CommonEqualityComparer.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Command 2 | { 3 | public class CommonEqualityComparer : IEqualityComparer 4 | { 5 | private Func keySelector; 6 | 7 | public CommonEqualityComparer(Func keySelector) 8 | { 9 | this.keySelector = keySelector; 10 | } 11 | 12 | public bool Equals(T x, T y) 13 | { 14 | return EqualityComparer.Default.Equals(keySelector(x), keySelector(y)); 15 | } 16 | 17 | public int GetHashCode(T obj) 18 | { 19 | return EqualityComparer.Default.GetHashCode(keySelector(obj)); 20 | } 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/Help/Help.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | 3 | namespace Discord_Stream_Notify_Bot.Command.Help 4 | { 5 | public class Help : TopLevelModule 6 | { 7 | private readonly CommandService _cmds; 8 | private readonly IServiceProvider _services; 9 | 10 | public const string PatreonUrl = "https://patreon.com/jun112561"; 11 | public const string PaypalUrl = "https://paypal.me/jun112561"; 12 | public Help(CommandService cmds, IServiceProvider service) 13 | { 14 | _cmds = cmds; 15 | _services = service; 16 | } 17 | 18 | [Command("Modules")] 19 | [Summary("顯示模組")] 20 | public async Task Modules() 21 | { 22 | await ReplyAsync("", false, new EmbedBuilder().WithOkColor().WithTitle("模組清單") 23 | .WithDescription(string.Join("\n", _cmds.Modules.Select((x) => "。" + x.Name))) 24 | .WithFooter("輸入 `s!Commands 模組名稱` 以顯示模組內全部的指令,例 `s!Commands Help`") 25 | .Build()); 26 | } 27 | 28 | [Command("Commands")] 29 | [Summary("顯示模組內包含的指令")] 30 | [Alias("Cmds")] 31 | public async Task Commands([Summary("模組名稱")] string module = null) 32 | { 33 | module = module?.Trim(); 34 | if (string.IsNullOrWhiteSpace(module)) return; 35 | 36 | var cmds = _cmds.Commands.Where(c => c.Module.Name.ToUpperInvariant().StartsWith(module.ToUpperInvariant(), StringComparison.InvariantCulture)).OrderBy(c => c.Aliases[0]).Distinct(new CommandTextEqualityComparer()); 37 | if (cmds.Count() == 0) { await Context.Channel.SendConfirmAsync($"找不到 {module} 模組"); return; } 38 | 39 | var succ = new HashSet((await Task.WhenAll(cmds.Select(async x => 40 | { 41 | var pre = (await x.CheckPreconditionsAsync(Context, _services).ConfigureAwait(false)); 42 | return (Cmd: x, Succ: pre.IsSuccess); 43 | })).ConfigureAwait(false)) 44 | .Where(x => x.Succ) 45 | .Select(x => x.Cmd)); 46 | cmds = cmds.Where(x => succ.Contains(x)); 47 | 48 | if (cmds.Count() == 0) 49 | { 50 | await Context.Channel.SendConfirmAsync(module + " 未包含你可使用的指令"); 51 | return; 52 | } 53 | 54 | var embed = new EmbedBuilder().WithOkColor().WithTitle($"{cmds.First().Module.Name} 內包含的指令").WithFooter("輸入 `s!Help 指令` 以顯示指令的詳細說明,例 `s!Help Help`"); 55 | var commandList = new List(); 56 | 57 | foreach (var item in cmds) 58 | { 59 | var prefix = "s!"; 60 | 61 | var str = string.Format("**`{0}`**", prefix + item.Aliases.First()); 62 | var alias = item.Aliases.Skip(1).FirstOrDefault(); 63 | if (alias != null) 64 | str += string.Format(" **/ `{0}`**", prefix + alias); 65 | 66 | if (!commandList.Contains(str)) commandList.Add(str); 67 | } 68 | embed.WithDescription(string.Join('\n', commandList)); 69 | 70 | await ReplyAsync("", false, embed.Build()); 71 | } 72 | 73 | [Command("Help")] 74 | [Summary("顯示指令的詳細說明")] 75 | [Alias("H")] 76 | public async Task H([Summary("指令名稱")] string command = null) 77 | { 78 | command = command?.Trim(); 79 | 80 | if (string.IsNullOrWhiteSpace(command)) 81 | { 82 | EmbedBuilder embed = new EmbedBuilder().WithOkColor().WithFooter("輸入 `s!Modules` 取得所有的模組"); 83 | embed.Title = "直播小幫手 建置版本" + Program.Version; 84 | #if DEBUG || DEBUG_DONTREGISTERCOMMAND 85 | embed.Title += " (測試版)"; 86 | #endif 87 | embed.WithDescription(System.IO.File.ReadAllText(Utility.GetDataFilePath("HelpDescription.txt")).Replace("\\n", "\n") + $"\n\n您可以透過:\nPatreon <{PatreonUrl}> \nPaypal <{PaypalUrl}>\n來贊助直播小幫手"); 88 | await ReplyAsync("", false, embed.Build()); 89 | return; 90 | } 91 | 92 | CommandInfo commandInfo = _cmds.Commands.FirstOrDefault((x) => x.Aliases.Any((x2) => x2.ToLowerInvariant() == command.ToLowerInvariant())); 93 | if (commandInfo == null) { await Context.Channel.SendConfirmAsync($"找不到 {command} 指令"); return; } 94 | 95 | await ReplyAsync("", false, _service.GetCommandHelp(commandInfo).Build()); 96 | } 97 | } 98 | 99 | public class CommandTextEqualityComparer : IEqualityComparer 100 | { 101 | public bool Equals(CommandInfo x, CommandInfo y) => x.Aliases[0] == y.Aliases[0]; 102 | 103 | public int GetHashCode(CommandInfo obj) => obj.Aliases[0].GetHashCode(StringComparison.InvariantCulture); 104 | 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/Help/HelpService.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | using Discord_Stream_Notify_Bot.Command.Attribute; 3 | 4 | namespace Discord_Stream_Notify_Bot.Command.Help 5 | { 6 | public class HelpService : ICommandService 7 | { 8 | public EmbedBuilder GetCommandHelp(CommandInfo com) 9 | { 10 | var prefix = "s!"; 11 | 12 | var str = string.Format("**`{0}`**", prefix + com.Aliases.First()); 13 | var alias = com.Aliases.Skip(1).FirstOrDefault(); 14 | if (alias != null) 15 | str += string.Format(" **/ `{0}`**", prefix + alias); 16 | var em = new EmbedBuilder().WithTitle(com.Name) 17 | .AddField(fb => fb.WithName(str) 18 | .WithValue(com.Summary) 19 | .WithIsInline(true)); 20 | 21 | if (com.Parameters.Count > 0) 22 | { 23 | string par = ""; 24 | foreach (var item in com.Parameters) 25 | par += item.Name + " " + item.Summary + "\n"; 26 | em.AddField("參數", par.TrimEnd('\n')); 27 | } 28 | 29 | var reqs = GetCommandRequirements(com); 30 | if (reqs.Any()) em.AddField("指令執行者權限要求", string.Join("\n", reqs)); 31 | 32 | var botReqs = GetBotCommandRequirements(com); 33 | if (botReqs.Any()) em.AddField("Bot權限要求", string.Join("\n", botReqs)); 34 | 35 | var exp = GetCommandExampleString(com); 36 | if (!string.IsNullOrEmpty(exp)) em.AddField("例子", exp); 37 | 38 | em.WithFooter(efb => efb.WithText("模組: " + com.Module.Name)) 39 | .WithOkColor(); 40 | 41 | return em; 42 | } 43 | 44 | public static string[] GetCommandRequirements(CommandInfo cmd) => 45 | cmd.Preconditions 46 | .Where(ca => ca is RequireOwnerAttribute || ca is RequireUserPermissionAttribute) 47 | .Select(ca => 48 | { 49 | if (ca is RequireOwnerAttribute) 50 | { 51 | return "Bot擁有者限定"; 52 | } 53 | 54 | var cau = (RequireUserPermissionAttribute)ca; 55 | if (cau.GuildPermission != null) 56 | { 57 | return ("伺服器 " + cau.GuildPermission.ToString() + " 權限") 58 | .Replace("Guild", "Server", StringComparison.InvariantCulture); 59 | } 60 | 61 | return ("頻道 " + cau.ChannelPermission + " 權限") 62 | .Replace("Guild", "Server", StringComparison.InvariantCulture); 63 | }) 64 | .ToArray(); 65 | 66 | public static string[] GetBotCommandRequirements(CommandInfo cmd) => 67 | cmd.Preconditions 68 | .Where(ca => ca is RequireBotPermissionAttribute) 69 | .Select(ca => 70 | { 71 | var cau = (RequireBotPermissionAttribute)ca; 72 | if (cau.GuildPermission != null) 73 | { 74 | return ("伺服器 " + cau.GuildPermission.ToString() + " 權限") 75 | .Replace("Guild", "Server", StringComparison.InvariantCulture); 76 | } 77 | 78 | return ("頻道 " + cau.ChannelPermission + " 權限") 79 | .Replace("Guild", "Server", StringComparison.InvariantCulture); 80 | }) 81 | .ToArray(); 82 | 83 | public static string GetCommandExampleString(CommandInfo cmd) 84 | { 85 | var att = cmd.Attributes.FirstOrDefault((x) => x is CommandExampleAttribute); 86 | if (att == null) return ""; 87 | 88 | var commandExampleAttribute = att as CommandExampleAttribute; 89 | 90 | return string.Join("\n", commandExampleAttribute.ExpArray 91 | .Select((x) => "`s!" + (cmd.Aliases.Count >= 2 ? cmd.Aliases.Last() : cmd.Name) + $" {x}`") 92 | .ToArray()); 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/ICommandService.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Command 2 | { 3 | public interface ICommandService 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/Normal/Normal.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | 3 | namespace Discord_Stream_Notify_Bot.Command.Normal 4 | { 5 | public class Normal : TopLevelModule 6 | { 7 | private readonly DiscordSocketClient _client; 8 | private readonly HttpClients.DiscordWebhookClient _discordWebhookClient; 9 | 10 | public Normal(DiscordSocketClient client, HttpClients.DiscordWebhookClient discordWebhookClient) 11 | { 12 | _client = client; 13 | _discordWebhookClient = discordWebhookClient; 14 | } 15 | 16 | [Command("Ping")] 17 | [Summary("延遲檢測")] 18 | public async Task PingAsync() 19 | { 20 | await Context.Channel.SendConfirmAsync(":ping_pong: " + _client.Latency.ToString() + "ms"); 21 | } 22 | 23 | 24 | [Command("Invite")] 25 | [Summary("取得邀請連結")] 26 | public async Task InviteAsync() 27 | { 28 | try 29 | { 30 | await (await Context.Message.Author.CreateDMChannelAsync()) 31 | .SendConfirmAsync(""); 32 | } 33 | catch (Exception) { await Context.Channel.SendErrorAsync("無法私訊,請確認已開啟伺服器內成員私訊許可"); } 34 | } 35 | 36 | [Command("Status")] 37 | [Summary("顯示機器人目前的狀態")] 38 | [Alias("Stats")] 39 | public async Task StatusAsync() 40 | { 41 | EmbedBuilder embedBuilder = new EmbedBuilder().WithOkColor(); 42 | embedBuilder.WithTitle("直播小幫手"); 43 | #if DEBUG || DEBUG_DONTREGISTERCOMMAND 44 | embedBuilder.Title += " (測試版)"; 45 | #endif 46 | 47 | embedBuilder.WithDescription($"建置版本 {Program.Version}"); 48 | embedBuilder.AddField("作者", "孤之界#1121", true); 49 | embedBuilder.AddField("擁有者", $"{Bot.ApplicatonOwner.Username}#{Bot.ApplicatonOwner.Discriminator}", true); 50 | embedBuilder.AddField("狀態", $"伺服器 {_client.Guilds.Count}\n服務成員數 {_client.Guilds.Sum((x) => x.MemberCount)}", false); 51 | embedBuilder.AddField("看過的直播數量", Utility.GetDbStreamCount(), true); 52 | embedBuilder.AddField("上線時間", $"{Bot.StopWatch.Elapsed:d\\天\\ hh\\:mm\\:ss}", false); 53 | 54 | await ReplyAsync(null, false, embedBuilder.Build()); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/ReactionEventWrapper.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Command 2 | { 3 | public sealed class ReactionEventWrapper : IDisposable 4 | { 5 | public IUserMessage Message { get; } 6 | public event Action OnReactionAdded = delegate { }; 7 | public event Action OnReactionRemoved = delegate { }; 8 | public event Action OnReactionsCleared = delegate { }; 9 | 10 | public ReactionEventWrapper(DiscordSocketClient client, IUserMessage msg) 11 | { 12 | Message = msg ?? throw new ArgumentNullException(nameof(msg)); 13 | _client = client; 14 | 15 | _client.ReactionAdded += Discord_ReactionAdded; 16 | _client.ReactionRemoved += Discord_ReactionRemoved; 17 | _client.ReactionsCleared += Discord_ReactionsCleared; 18 | } 19 | 20 | private Task Discord_ReactionsCleared(Cacheable user, Cacheable channel) 21 | { 22 | Task.Run(async () => 23 | { 24 | try 25 | { 26 | if ((await user.GetOrDownloadAsync()).Id == Message.Id) 27 | OnReactionsCleared?.Invoke(); 28 | } 29 | catch { } 30 | }); 31 | 32 | return Task.CompletedTask; 33 | } 34 | 35 | private Task Discord_ReactionRemoved(Cacheable user, Cacheable channel, SocketReaction reaction) 36 | { 37 | Task.Run(async () => 38 | { 39 | try 40 | { 41 | if ((await user.GetOrDownloadAsync()).Id == Message.Id) 42 | OnReactionRemoved?.Invoke(reaction); 43 | } 44 | catch { } 45 | }); 46 | 47 | return Task.CompletedTask; 48 | } 49 | 50 | private Task Discord_ReactionAdded(Cacheable user, Cacheable channel, SocketReaction reaction) 51 | { 52 | Task.Run(async () => 53 | { 54 | try 55 | { 56 | if ((await user.GetOrDownloadAsync()).Id == Message.Id) 57 | OnReactionAdded?.Invoke(reaction); 58 | } 59 | catch { } 60 | }); 61 | 62 | return Task.CompletedTask; 63 | } 64 | 65 | public void UnsubAll() 66 | { 67 | _client.ReactionAdded -= Discord_ReactionAdded; 68 | _client.ReactionRemoved -= Discord_ReactionRemoved; 69 | _client.ReactionsCleared -= Discord_ReactionsCleared; 70 | OnReactionAdded = null; 71 | OnReactionRemoved = null; 72 | OnReactionsCleared = null; 73 | } 74 | 75 | private bool disposing = false; 76 | private readonly DiscordSocketClient _client; 77 | 78 | public void Dispose() 79 | { 80 | if (disposing) 81 | return; 82 | disposing = true; 83 | UnsubAll(); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/TopLevelModule.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | 3 | namespace Discord_Stream_Notify_Bot.Command 4 | { 5 | public abstract class TopLevelModule : ModuleBase 6 | { 7 | public async Task PromptUserConfirmAsync(EmbedBuilder embed) 8 | { 9 | embed.WithOkColor() 10 | .WithFooter("yes/no"); 11 | 12 | var msg = await Context.Channel.EmbedAsync(embed).ConfigureAwait(false); 13 | try 14 | { 15 | var input = await GetUserInputAsync(Context.User.Id, Context.Channel.Id).ConfigureAwait(false); 16 | input = input?.ToUpperInvariant(); 17 | 18 | if (input != "YES" && input != "Y") 19 | { 20 | return false; 21 | } 22 | 23 | return true; 24 | } 25 | finally 26 | { 27 | var _ = Task.Run(() => msg.DeleteAsync()); 28 | } 29 | } 30 | 31 | public async Task GetUserInputAsync(ulong userId, ulong channelId) 32 | { 33 | var userInputTask = new TaskCompletionSource(); 34 | var dsc = (DiscordSocketClient)Context.Client; 35 | try 36 | { 37 | dsc.MessageReceived += MessageReceived; 38 | 39 | if ((await Task.WhenAny(userInputTask.Task, Task.Delay(10000)).ConfigureAwait(false)) != userInputTask.Task) 40 | { 41 | return null; 42 | } 43 | 44 | return await userInputTask.Task.ConfigureAwait(false); 45 | } 46 | finally 47 | { 48 | dsc.MessageReceived -= MessageReceived; 49 | } 50 | 51 | Task MessageReceived(SocketMessage arg) 52 | { 53 | var _ = Task.Run(() => 54 | { 55 | if (!(arg is SocketUserMessage userMsg) || 56 | userMsg.Author.Id != userId || 57 | userMsg.Channel.Id != channelId) 58 | { 59 | return Task.CompletedTask; 60 | } 61 | 62 | if (userInputTask.TrySetResult(arg.Content)) 63 | { 64 | userMsg.DeleteAfter(1); 65 | } 66 | return Task.CompletedTask; 67 | }); 68 | return Task.CompletedTask; 69 | } 70 | } 71 | } 72 | 73 | public abstract class TopLevelModule : TopLevelModule where TService : ICommandService 74 | { 75 | protected TopLevelModule() 76 | { 77 | } 78 | 79 | public TService _service { get; set; } 80 | 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/TwitCasting/TwitCasting.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | using Discord_Stream_Notify_Bot.DataBase; 3 | using Discord_Stream_Notify_Bot.HttpClients; 4 | 5 | namespace Discord_Stream_Notify_Bot.Command.TwitCasting 6 | { 7 | public class TwitCasting : TopLevelModule 8 | { 9 | private readonly MainDbService _mainDbService; 10 | private readonly TwitcastingClient _twitcastingClient; 11 | 12 | public TwitCasting(MainDbService mainDbService, TwitcastingClient twitcastingClient) 13 | { 14 | _mainDbService = mainDbService; 15 | _twitcastingClient = twitcastingClient; 16 | } 17 | 18 | [RequireContext(ContextType.DM)] 19 | [Command("FixTCDb")] 20 | [Alias("ftcdb")] 21 | [RequireOwner] 22 | public async Task FixTCDbAsync() 23 | { 24 | await Context.Channel.TriggerTypingAsync().ConfigureAwait(false); 25 | 26 | using var db = _mainDbService.GetDbContext(); 27 | var needFixList = db.TwitcastingSpider.Where((x) => string.IsNullOrEmpty(x.ChannelId)).ToList(); 28 | 29 | foreach (var spider in needFixList) 30 | { 31 | var userInfo = await _twitcastingClient.GetUserInfoAsync(spider.ScreenId).ConfigureAwait(false); 32 | if (userInfo != null) 33 | { 34 | spider.ChannelId = userInfo.User.Id; 35 | } 36 | else 37 | { 38 | Log.Error($"Failed to fix TwitCasting Spider for ScreenId: {spider.ScreenId}"); 39 | } 40 | } 41 | 42 | await db.SaveChangesAsync().ConfigureAwait(false); 43 | 44 | await Context.Channel.SendConfirmAsync("Fix TwitCasting Spider Database", $"Fixed {needFixList.Count} entries.").ConfigureAwait(false); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/Twitch/Twitch.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | using Discord_Stream_Notify_Bot.Command.Attribute; 3 | using Discord_Stream_Notify_Bot.DataBase; 4 | using Discord_Stream_Notify_Bot.DataBase.Table; 5 | 6 | namespace Discord_Stream_Notify_Bot.Command.Twitch 7 | { 8 | public partial class Twitch : TopLevelModule, ICommandService 9 | { 10 | private readonly SharedService.Twitch.TwitchService _service; 11 | private readonly MainDbService _dbService; 12 | 13 | public Twitch(SharedService.Twitch.TwitchService service, MainDbService dbService) 14 | { 15 | _service = service; 16 | _dbService = dbService; 17 | } 18 | 19 | [RequireContext(ContextType.DM)] 20 | [RequireOwner] 21 | [Command("TwitchAddSpiderToGuild")] 22 | [Summary("新增爬蟲並指定伺服器")] 23 | [Alias("tastg")] 24 | public async Task AddSpiderToGuild(string channelUrl, ulong guildId) 25 | { 26 | string userLogin = _service.GetUserLoginByUrl(channelUrl); 27 | 28 | using (var db = _dbService.GetDbContext()) 29 | { 30 | var twitchSpider = await db.TwitchSpider.AsNoTracking().SingleOrDefaultAsync((x) => x.UserLogin == userLogin); 31 | if (twitchSpider != null) 32 | { 33 | await Context.Channel.SendErrorAsync($"`{userLogin}` 已被 `{twitchSpider.GuildId}` 設定").ConfigureAwait(false); 34 | return; 35 | } 36 | 37 | var user = await _service.GetUserAsync(twitchUserLogin: userLogin); 38 | if (user == null) 39 | { 40 | await Context.Channel.SendErrorAsync($"頻道 `{userLogin}` 不存在").ConfigureAwait(false); 41 | return; 42 | } 43 | 44 | db.TwitchSpider.Add(new TwitchSpider() { UserId = user.Id, GuildId = guildId, UserLogin = user.Login, IsWarningUser = false, UserName = user.DisplayName }); 45 | await db.SaveChangesAsync(); 46 | 47 | await Context.Channel.SendConfirmAsync($"已將 `{user.DisplayName}` (`{user.Login}`) 設定至 `{guildId}`").ConfigureAwait(false); 48 | } 49 | } 50 | 51 | [RequireContext(ContextType.DM)] 52 | [RequireOwner] 53 | [Command("TwitchSetChannelSpiderGuildId")] 54 | [Summary("設定爬蟲頻道的伺服器 Id")] 55 | [CommandExample("https://twitch.tv/998rrr 0")] 56 | [Alias("tscsg")] 57 | public async Task SetChannelSpiderGuildId([Summary("頻道網址")] string channelUrl, ulong guildId = 0) 58 | { 59 | string userLogin = _service.GetUserLoginByUrl(channelUrl); 60 | 61 | using (var db = _dbService.GetDbContext()) 62 | { 63 | var twitchSpider = await db.TwitchSpider.SingleOrDefaultAsync((x) => x.UserLogin == userLogin); 64 | if (twitchSpider != null) 65 | { 66 | twitchSpider.GuildId = guildId; 67 | db.TwitchSpider.Update(twitchSpider); 68 | await db.SaveChangesAsync(); 69 | 70 | await Context.Channel.SendConfirmAsync($"已設定 `{twitchSpider.UserName}` (`{twitchSpider.UserLogin}`) 的 GuildId 為 `{guildId}`").ConfigureAwait(false); 71 | } 72 | else 73 | { 74 | await Context.Channel.SendErrorAsync($"尚未設定 `{userLogin}` 的爬蟲").ConfigureAwait(false); 75 | } 76 | } 77 | } 78 | 79 | [RequireContext(ContextType.DM)] 80 | [RequireOwner] 81 | [Command("TwitchToggleIsTrustedChannel")] 82 | [Summary("切換頻道是否為認可頻道")] 83 | [CommandExample("https://twitch.tv/998rrr")] 84 | [Alias("tttc")] 85 | public async Task ToggleIsTrustedChannel([Summary("頻道網址")] string channelUrl = "") 86 | { 87 | string userLogin = _service.GetUserLoginByUrl(channelUrl); 88 | 89 | using (var db = _dbService.GetDbContext()) 90 | { 91 | var twitchSpider = await db.TwitchSpider.SingleOrDefaultAsync((x) => x.UserLogin == userLogin); 92 | if (twitchSpider != null) 93 | { 94 | twitchSpider.IsWarningUser = !twitchSpider.IsWarningUser; 95 | db.TwitchSpider.Update(twitchSpider); 96 | await db.SaveChangesAsync(); 97 | 98 | await Context.Channel.SendConfirmAsync($"已設定 `{twitchSpider.UserName}` (`{twitchSpider.UserLogin}`) 為 __" + (twitchSpider.IsWarningUser ? "已" : "未") + "__ 認可頻道").ConfigureAwait(false); 99 | } 100 | else 101 | { 102 | await Context.Channel.SendErrorAsync($"尚未設定 `{userLogin}` 的爬蟲").ConfigureAwait(false); 103 | } 104 | } 105 | } 106 | 107 | [RequireContext(ContextType.DM)] 108 | [RequireOwner] 109 | [Command("TwitchRemoveChannelSpider")] 110 | [Summary("移除頻道爬蟲")] 111 | [CommandExample("https://twitch.tv/998rrr")] 112 | [Alias("trcs")] 113 | public async Task RemoveChannelSpider([Summary("頻道網址")] string channelUrl = "") 114 | { 115 | string userLogin = _service.GetUserLoginByUrl(channelUrl); 116 | 117 | using (var db = _dbService.GetDbContext()) 118 | { 119 | var twitchSpider = await db.TwitchSpider.SingleOrDefaultAsync((x) => x.UserLogin == userLogin); 120 | if (twitchSpider != null) 121 | { 122 | db.TwitchSpider.Remove(twitchSpider); 123 | await db.SaveChangesAsync(); 124 | 125 | await Context.Channel.SendConfirmAsync($"已移除 `{twitchSpider.UserName}` 的爬蟲").ConfigureAwait(false); 126 | } 127 | else 128 | { 129 | await Context.Channel.SendErrorAsync($"尚未設定 `{userLogin}` 的爬蟲").ConfigureAwait(false); 130 | } 131 | } 132 | } 133 | 134 | [RequireContext(ContextType.DM)] 135 | [RequireOwner] 136 | [Command("CreateEventSubSubscription")] 137 | [Summary("建立事件訂閱頻道")] 138 | [CommandExample("174268844")] 139 | [Alias("cess")] 140 | public async Task CreateEventSubSubscriptionAsync(string broadcasterUserId) 141 | { 142 | await Context.Channel.TriggerTypingAsync(); 143 | 144 | if (await _service.CreateEventSubSubscriptionAsync(broadcasterUserId)) 145 | { 146 | await Context.Channel.SendConfirmAsync($"已註冊圖奇事件通知: {broadcasterUserId}"); 147 | } 148 | else 149 | { 150 | await Context.Channel.SendErrorAsync($"圖奇事件通知註冊失敗"); 151 | } 152 | } 153 | 154 | [RequireContext(ContextType.DM)] 155 | [RequireOwner] 156 | [Command("TwitchGetLatestVOD")] 157 | [Summary("取得 Twitch 頻道最新的 VOD 資訊")] 158 | [CommandExample("https://twitch.tv/998rrr")] 159 | [Alias("tglv")] 160 | public async Task GetLatestVOD([Summary("頻道網址")] string channelUrl) 161 | { 162 | try 163 | { 164 | await Context.Channel.TriggerTypingAsync(); 165 | 166 | string userLogin = _service.GetUserLoginByUrl(channelUrl); 167 | 168 | var user = await _service.GetUserAsync(twitchUserLogin: userLogin); 169 | if (user == null) 170 | { 171 | await Context.Channel.SendErrorAsync("找不到對應的 User 資料"); 172 | return; 173 | } 174 | 175 | var video = await _service.GetLatestVODAsync(user.Id); 176 | if (video == null) 177 | { 178 | await Context.Channel.SendErrorAsync("找不到對應的 Video 資料"); 179 | return; 180 | } 181 | 182 | var createAt = DateTime.Parse(video.CreatedAt); 183 | var endAt = createAt + _service.ParseToTimeSpan(video.Duration); 184 | var clips = await _service.GetClipsAsync(video.UserId, createAt, endAt); 185 | 186 | Log.Debug($"Duration: {video.Duration} | createAt: {createAt} | endAt: {endAt}"); 187 | 188 | var embedBuilder = new EmbedBuilder() 189 | .WithOkColor() 190 | .WithTitle(video.Title) 191 | .WithUrl(video.Url) 192 | .WithDescription($"{Format.Url($"{video.UserName}", $"https://twitch.tv/{video.UserLogin}")}") 193 | .WithImageUrl(video.ThumbnailUrl.Replace("%{width}", "854").Replace("%{height}", "480")) 194 | .AddField("開始時間", createAt.ConvertDateTimeToDiscordMarkdown()) 195 | .AddField("結束時間", endAt.ConvertDateTimeToDiscordMarkdown()) 196 | .AddField("直播時長", video.Duration.Replace("h", "時").Replace("m", "分").Replace("s", "秒")); 197 | 198 | if (clips != null && clips.Any((x) => x.VideoId == video.Id)) 199 | { 200 | Log.Debug(JsonConvert.SerializeObject(clips)); 201 | int i = 0; 202 | embedBuilder.AddField("最多觀看的 Clip", string.Join('\n', clips.Where((x) => x.VideoId == video.Id) 203 | .Select((x) => $"{i++}. {Format.Url(x.Title, x.Url)} By `{x.CreatorName}` (`{x.ViewCount}` 次觀看)"))); 204 | } 205 | 206 | await Context.Channel.SendMessageAsync(embed: embedBuilder.Build()); 207 | } 208 | catch (Exception ex) 209 | { 210 | Log.Error(ex.Demystify(), "TwitchGetLatestVOD Error"); 211 | await Context.Channel.SendErrorAsync("Error"); 212 | } 213 | } 214 | } 215 | } 216 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Command/YoutubeMember/YoutubeMember.cs: -------------------------------------------------------------------------------- 1 | using Discord.Commands; 2 | using Discord_Stream_Notify_Bot.DataBase; 3 | 4 | namespace Discord_Stream_Notify_Bot.Command.YoutubeMember 5 | { 6 | public class YoutubeMember : TopLevelModule, ICommandService 7 | { 8 | private readonly SharedService.Youtube.YoutubeStreamService _service; 9 | private readonly SharedService.YoutubeMember.YoutubeMemberService _ytMemberService; 10 | private readonly MainDbService _dbService; 11 | 12 | public YoutubeMember(SharedService.Youtube.YoutubeStreamService service, SharedService.YoutubeMember.YoutubeMemberService youtubeMemberService, MainDbService dbService) 13 | { 14 | _service = service; 15 | _ytMemberService = youtubeMemberService; 16 | _dbService = dbService; 17 | } 18 | 19 | [Command("ListAllGuildCheckedMember")] 20 | [Summary("顯示所有伺服器已完成驗證的會員數量")] 21 | [Alias("lagcm")] 22 | [RequireContext(ContextType.DM)] 23 | [RequireOwner] 24 | public async Task ListAllGuildCheckedMemberAsync(int page = 0) 25 | { 26 | using (var db = _dbService.GetDbContext()) 27 | { 28 | var guildYoutubeMemberConfigs = db.GuildYoutubeMemberConfig 29 | .AsNoTracking() 30 | .Where((x) => !string.IsNullOrEmpty(x.MemberCheckChannelTitle) && x.MemberCheckVideoId != "-"); 31 | if (!guildYoutubeMemberConfigs.Any()) 32 | { 33 | await Context.Channel.SendErrorAsync($"清單為空"); 34 | return; 35 | } 36 | 37 | var dic = new Dictionary>(); 38 | foreach (var item in guildYoutubeMemberConfigs) 39 | { 40 | var checkedMemberCount = db.YoutubeMemberCheck.Count((x) => x.GuildId == item.GuildId && 41 | x.CheckYTChannelId == item.MemberCheckChannelId && x.IsChecked); 42 | 43 | if (checkedMemberCount == 0) 44 | continue; 45 | 46 | string guildName = (await Context.Client.GetGuildAsync(item.GuildId)).Name; 47 | string formatStr = $"{Format.Url(item.MemberCheckChannelTitle, $"https://www.youtube.com/channel/{item.MemberCheckChannelId}")}: {checkedMemberCount}人"; 48 | 49 | if (dic.TryGetValue(guildName, out List value)) value.Add(formatStr); 50 | else dic.Add(guildName, new List() { formatStr }); 51 | } 52 | 53 | await Context.SendPaginatedConfirmAsync(page, (page) => 54 | { 55 | return new EmbedBuilder().WithOkColor().WithDescription(string.Join('\n', dic 56 | .Skip(page * 7).Take(7).Select((x) => 57 | $"**{x.Key}**:\n" + 58 | $"{string.Join('\n', x.Value)}\n" 59 | ))); 60 | }, dic.Count(), 7); 61 | } 62 | } 63 | 64 | [Command("SetMemberCheckVideoId")] 65 | [Summary("設定指定頻道的會限影片Id")] 66 | [Alias("smcvi")] 67 | [RequireContext(ContextType.DM)] 68 | [RequireOwner] 69 | public async Task SetMemberCheckVideoIdAsync(string channelId, string videoId) 70 | { 71 | try 72 | { 73 | channelId = await _service.GetChannelIdAsync(channelId).ConfigureAwait(false); 74 | videoId = _service.GetVideoId(videoId); 75 | } 76 | catch (FormatException fex) 77 | { 78 | await Context.Channel.SendErrorAsync(fex.Message); 79 | return; 80 | } 81 | catch (ArgumentNullException) 82 | { 83 | await Context.Channel.SendErrorAsync("網址不可空白"); 84 | return; 85 | } 86 | 87 | try 88 | { 89 | using (var db = _dbService.GetDbContext()) 90 | { 91 | var guildYoutubeMemberConfigs = db.GuildYoutubeMemberConfig.Where((x) => x.MemberCheckChannelId == channelId); 92 | if (!guildYoutubeMemberConfigs.Any()) 93 | { 94 | await Context.Channel.SendErrorAsync($"{channelId} 不存在資料"); 95 | return; 96 | } 97 | 98 | foreach (var guildYoutubeMemberConfig in guildYoutubeMemberConfigs) 99 | { 100 | guildYoutubeMemberConfig.MemberCheckVideoId = videoId; 101 | db.GuildYoutubeMemberConfig.Update(guildYoutubeMemberConfig); 102 | } 103 | 104 | await db.SaveChangesAsync(); 105 | await Context.Channel.SendConfirmAsync($"已將 `{guildYoutubeMemberConfigs.First().MemberCheckChannelTitle}` 的會限檢測影片更改為 `{guildYoutubeMemberConfigs.First().MemberCheckVideoId}`"); 106 | } 107 | } 108 | catch (Exception ex) 109 | { 110 | Log.Error(ex.ToString()); 111 | await Context.Channel.SendErrorAsync(ex.Message); 112 | } 113 | } 114 | 115 | [Command("StartNewMemberCheck")] 116 | [Summary("開始新會員的會限驗證")] 117 | [Alias("snmc")] 118 | [RequireContext(ContextType.DM)] 119 | [RequireOwner] 120 | public async Task StartNewMemberCheck() 121 | { 122 | await _ytMemberService.CheckMemberShip(false); 123 | 124 | await Context.Channel.SendConfirmAsync(":ok:"); 125 | } 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Data/HelpDescription.txt: -------------------------------------------------------------------------------- 1 | 由 孤之界(konnokai) 製作 2 | 3 | 可以幫你處理以下功能 4 | 1. YouTube 通知直播待機所建立/開台/關台(部分頻道) 5 | 2. YouTube 影片上傳通知(非主要功能) 6 | 3. YouTube 會限驗證並自動授予用戶組 7 | 4. TwitterSpace 開台通知 8 | 5. TwitCasting 開台通知 9 | 6. Twitch 開台通知 10 | 11 | __**其他特殊功能**__ 12 | 部分頻道會有錄影,可以使用 `/youtube now-record-channel` 跟 `/youtube list-record-channel` 查詢 13 | 如果伺服器 Boost 達 Lv2 的話可以使用 `/youtube set-banner-change 頻道網址`,設定伺服器橫幅使用指定頻道的最新影片(直播)縮圖 14 | (可用 `/help get-command-help set-banner-change` 查看詳情) 15 | 16 | 邀請 Bot 後請開一個新的文字頻道做為直播通知用,並且開放 Bot 的讀取、發送訊息權限 (最好的做法是不要變更預設權限或直接給全權) 17 | 設定可至 [此網頁](https://konnokai.notion.site/a4fff40bd95c4bec9edca5b78cdd5d37) 查看詳細說明 18 | 19 | [點此邀請 Bot 到新伺服器](https://discordapp.com/api/oauth2/authorize?client_id=758222559392432160&permissions=2416143425&scope=bot%20applications.commands) 20 | 21 | 有新功能想加入或任何建議可以到推特私訊 [孤之界(@Jun112561)](https://twitter.com/Jun112561) 或到 [GitHub](https://github.com/konnokai/Discord-Stream-Notify-Bot) 發 Issue 討論 -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/MainDbContext.cs: -------------------------------------------------------------------------------- 1 | using Discord_Stream_Notify_Bot.DataBase.Table; 2 | 3 | namespace Discord_Stream_Notify_Bot.DataBase 4 | { 5 | public class MainDbContext : DbContext 6 | { 7 | private readonly string _connectionString; 8 | 9 | public MainDbContext(string connectionString 10 | // 要新增 Migration 的時候再把下面的連線字串註解拿掉 11 | //= "Server=localhost;Port=3306;User Id=stream_bot;Password=Ch@nge_Me;Database=discord_stream_bot" 12 | ) 13 | { 14 | _connectionString = connectionString; 15 | } 16 | 17 | public DbSet BannerChange { get; set; } 18 | public DbSet GuildConfig { get; set; } 19 | public DbSet GuildYoutubeMemberConfig { get; set; } 20 | public DbSet NoticeTwitcastingStreamChannels { get; set; } 21 | public DbSet NoticeTwitchStreamChannels { get; set; } 22 | public DbSet NoticeTwitterSpaceChannel { get; set; } 23 | public DbSet NoticeYoutubeStreamChannel { get; set; } 24 | public DbSet RecordYoutubeChannel { get; set; } 25 | public DbSet TwitcastingSpider { get; set; } 26 | public DbSet TwitchSpider { get; set; } 27 | public DbSet TwitterSpace { get; set; } 28 | public DbSet TwitterSpaceSpider { get; set; } 29 | public DbSet YoutubeChannelNameToId { get; set; } 30 | public DbSet YoutubeChannelOwnedType { get; set; } 31 | public DbSet YoutubeChannelSpider { get; set; } 32 | public DbSet YoutubeMemberAccessToken { get; set; } 33 | public DbSet YoutubeMemberCheck { get; set; } 34 | 35 | #region Video 36 | public DbSet HoloVideos { get; set; } 37 | public DbSet NijisanjiVideos { get; set; } 38 | public DbSet OtherVideos { get; set; } 39 | public DbSet NonApprovedVideos { get; set; } 40 | public DbSet TwitcastingStreams { get; set; } 41 | public DbSet TwitchStreams { get; set; } 42 | #endregion 43 | 44 | protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) 45 | { 46 | base.OnConfiguring(optionsBuilder); 47 | optionsBuilder 48 | .UseMySql(_connectionString, ServerVersion.AutoDetect(_connectionString)) 49 | .UseSnakeCaseNamingConvention(); 50 | } 51 | 52 | public bool UpdateAndSave(Table.Video video) 53 | { 54 | Table.Video updatedVideo = video switch 55 | { 56 | { ChannelType: Table.Video.YTChannelType.Holo } => video as HoloVideos, 57 | { ChannelType: Table.Video.YTChannelType.Nijisanji } => video as NijisanjiVideos, 58 | { ChannelType: Table.Video.YTChannelType.Other } => video as OtherVideos, 59 | { ChannelType: Table.Video.YTChannelType.NonApproved } => video as NonApprovedVideos, 60 | _ => null 61 | }; 62 | 63 | if (updatedVideo == null) 64 | { 65 | return false; 66 | } 67 | 68 | Update(updatedVideo); 69 | var saveTime = DateTime.Now; 70 | bool saveFailed; 71 | int retryCount = 0; 72 | const int maxRetryCount = 5; 73 | 74 | do 75 | { 76 | saveFailed = false; 77 | try 78 | { 79 | SaveChanges(); 80 | } 81 | catch (DbUpdateConcurrencyException ex) 82 | { 83 | saveFailed = true; 84 | retryCount++; 85 | foreach (var item in ex.Entries) 86 | { 87 | try 88 | { 89 | item.Reload(); 90 | } 91 | catch (Exception ex2) 92 | { 93 | Log.Error($"VideoContext-SaveChanges-Reload"); 94 | Log.Error(item.DebugView.ToString()); 95 | Log.Error(ex2.ToString()); 96 | } 97 | } 98 | } 99 | catch (Exception ex) 100 | { 101 | Log.Error($"VideoContext-SaveChanges: {ex}"); 102 | Log.Error(ChangeTracker.DebugView.LongView); 103 | } 104 | } while (saveFailed && retryCount < maxRetryCount && DateTime.Now.Subtract(saveTime) <= TimeSpan.FromMinutes(1)); 105 | 106 | return retryCount >= maxRetryCount || DateTime.Now.Subtract(saveTime) >= TimeSpan.FromMinutes(1); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/MainDbService.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase 2 | { 3 | public class MainDbService 4 | { 5 | private readonly string _connectionString; 6 | 7 | public MainDbService(string connectionString) 8 | { 9 | _connectionString = connectionString; 10 | } 11 | 12 | public MainDbContext GetDbContext() 13 | { 14 | return new MainDbContext(_connectionString); 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/BannerChange.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class BannerChange : DbEntity 4 | { 5 | public ulong GuildId { get; set; } 6 | public string ChannelId { get; set; } 7 | public string LastChangeStreamId { get; set; } = null; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/DbEntity.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Discord_Stream_Notify_Bot.DataBase.Table 4 | { 5 | public class DbEntity 6 | { 7 | [Key] 8 | public int Id { get; set; } 9 | public DateTime? DateAdded { get; set; } = DateTime.Now; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/GuildConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class GuildConfig : DbEntity 4 | { 5 | public ulong GuildId { get; set; } 6 | public ulong LogMemberStatusChannelId { get; set; } = 0; 7 | public ulong NoticeChannelId { get; set; } = 0; 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/GuildYoutubeMemberConfig.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class GuildYoutubeMemberConfig : DbEntity 4 | { 5 | public ulong GuildId { get; set; } 6 | public string MemberCheckChannelId { get; set; } = ""; 7 | public string MemberCheckChannelTitle { get; set; } = ""; 8 | public string MemberCheckVideoId { get; set; } = "-"; 9 | public ulong MemberCheckGrantRoleId { get; set; } = 0; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/HoloVideo.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class HoloVideos : Video 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/NijisanjiVideo.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class NijisanjiVideos : Video 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/NonApprovedVideo.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class NonApprovedVideos : Video 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/NoticeTwitcastingStreamChannel.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class NoticeTwitcastingStreamChannel : DbEntity 4 | { 5 | public ulong GuildId { get; set; } 6 | public ulong DiscordChannelId { get; set; } 7 | public string ScreenId { get; set; } 8 | public string StartStreamMessage { get; set; } = ""; 9 | } 10 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/NoticeTwitchStreamChannel.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class NoticeTwitchStreamChannel : DbEntity 4 | { 5 | public ulong GuildId { get; set; } 6 | public ulong DiscordChannelId { get; set; } 7 | public string NoticeTwitchUserId { get; set; } 8 | public string StartStreamMessage { get; set; } = ""; 9 | public string EndStreamMessage { get; set; } = ""; 10 | public string ChangeStreamDataMessage { get; set; } = ""; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/NoticeTwitterSpaceChannel.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class NoticeTwitterSpaceChannel : DbEntity 4 | { 5 | public ulong GuildId { get; set; } 6 | public ulong DiscordChannelId { get; set; } 7 | public string NoticeTwitterSpaceUserId { get; set; } 8 | public string NoticeTwitterSpaceUserScreenName { get; set; } 9 | public string StratTwitterSpaceMessage { get; set; } = ""; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/NoticeYoutubeStreamChannel.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class NoticeYoutubeStreamChannel : DbEntity 4 | { 5 | public ulong GuildId { get; set; } 6 | public ulong DiscordNoticeVideoChannelId { get; set; } 7 | public ulong DiscordNoticeStreamChannelId { get; set; } 8 | public bool IsCreateEventForNewStream { get; set; } = false; 9 | public string YouTubeChannelId { get; set; } 10 | public string NewStreamMessage { get; set; } = ""; 11 | public string NewVideoMessage { get; set; } = ""; 12 | public string StratMessage { get; set; } = ""; 13 | public string EndMessage { get; set; } = ""; 14 | public string ChangeTimeMessage { get; set; } = ""; 15 | public string DeleteMessage { get; set; } = ""; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/OtherVideo.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class OtherVideos : Video 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/RecordYoutubeChannel.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Discord_Stream_Notify_Bot.DataBase.Table 4 | { 5 | public class RecordYoutubeChannel 6 | { 7 | [Key] 8 | public string YoutubeChannelId { get; set; } 9 | public DateTime? DateAdded { get; set; } = DateTime.UtcNow; 10 | } 11 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/TwitcastingSpider.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class TwitcastingSpider : DbEntity 4 | { 5 | public ulong GuildId { get; set; } 6 | public string ChannelTitle { get; set; } 7 | public string ScreenId { get; set; } 8 | public string ChannelId { get; set; } 9 | public bool IsWarningUser { get; set; } = false; 10 | public bool IsRecord { get; set; } = false; 11 | } 12 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/TwitcastingStream.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class TwitcastingStream : DbEntity 4 | { 5 | public string ChannelId { get; set; } 6 | public string ChannelTitle { get; set; } 7 | public int StreamId { get; set; } 8 | public string StreamTitle { get; set; } = ""; 9 | public string StreamSubTitle { get; set; } = ""; 10 | public string Category { get; set; } = ""; 11 | public string ThumbnailUrl { get; set; } = ""; 12 | public DateTime StreamStartAt { get; set; } = DateTime.Now; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/TwitchSpider.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Discord_Stream_Notify_Bot.DataBase.Table 4 | { 5 | public class TwitchSpider 6 | { 7 | [Key] 8 | public string UserId { get; set; } 9 | public string UserLogin { get; set; } 10 | public string UserName { get; set; } 11 | public string ProfileImageUrl { get; set; } = ""; 12 | public string OfflineImageUrl { get; set; } = ""; 13 | public ulong GuildId { get; set; } 14 | public bool IsWarningUser { get; set; } = false; 15 | public bool IsRecord { get; set; } = false; 16 | public DateTime? DateAdded { get; set; } = DateTime.UtcNow; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/TwitchStream.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class TwitchStream : DbEntity 4 | { 5 | public string StreamId { get; set; } 6 | public string StreamTitle { get; set; } 7 | public DateTime StreamStartAt { get; set; } 8 | public string UserId { get; set; } 9 | public string UserLogin { get; set; } 10 | public string UserName { get; set; } 11 | public string GameName { get; set; } = ""; 12 | public string ThumbnailUrl { get; set; } = ""; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/TwitterSpace.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class TwitterSpace : DbEntity 4 | { 5 | public string UserId { get; set; } 6 | public string UserScreenName { get; set; } 7 | public string UserName { get; set; } 8 | public string SpaecId { get; set; } 9 | public string SpaecTitle { get; set; } 10 | public DateTime SpaecActualStartTime { get; set; } 11 | public string SpaecMasterPlaylistUrl { get; set; } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/TwitterSpaecSpider.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Discord_Stream_Notify_Bot.DataBase.Table 4 | { 5 | public class TwitterSpaceSpider 6 | { 7 | [Key] 8 | public string UserId { get; set; } 9 | public string UserScreenName { get; set; } = null; 10 | public string UserName { get; set; } = null; 11 | public ulong GuildId { get; set; } 12 | public bool IsWarningUser { get; set; } = false; 13 | public bool IsRecord { get; set; } = true; 14 | public DateTime? DateAdded { get; set; } = DateTime.UtcNow; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/Video.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Discord_Stream_Notify_Bot.DataBase.Table 4 | { 5 | public class Video 6 | { 7 | public enum YTChannelType 8 | { 9 | Holo, Nijisanji, Other, NonApproved 10 | } 11 | 12 | public string ChannelId { get; set; } 13 | public string ChannelTitle { get; set; } 14 | [Key] 15 | public string VideoId { get; set; } 16 | public string VideoTitle { get; set; } 17 | public DateTime ScheduledStartTime { get; set; } 18 | public YTChannelType ChannelType { get; set; } 19 | public bool IsPrivate { get; set; } = false; 20 | 21 | public override int GetHashCode() 22 | { 23 | return VideoId.ToCharArray().Sum((x) => x); 24 | } 25 | 26 | public override string ToString() 27 | { 28 | return ChannelTitle + " - " + VideoTitle; 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/YoutubeChannelNameToId.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class YoutubeChannelNameToId : DbEntity 4 | { 5 | public string ChannelName { get; set; } 6 | public string ChannelId { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/YoutubeChannelOwnedType.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Discord_Stream_Notify_Bot.DataBase.Table 4 | { 5 | public class YoutubeChannelOwnedType 6 | { 7 | 8 | [Key] 9 | public string ChannelId { get; set; } 10 | public string ChannelTitle { get; set; } = null; 11 | public Video.YTChannelType ChannelType { get; set; } = Video.YTChannelType.Other; 12 | public DateTime? DateAdded { get; set; } = DateTime.UtcNow; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/YoutubeChannelSpider.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Discord_Stream_Notify_Bot.DataBase.Table 4 | { 5 | public class YoutubeChannelSpider 6 | { 7 | [Key] 8 | public string ChannelId { get; set; } 9 | public string ChannelTitle { get; set; } = null; 10 | public ulong GuildId { get; set; } 11 | public bool IsTrustedChannel { get; set; } = false; 12 | public DateTime LastSubscribeTime { get; set; } = DateTime.MinValue; 13 | public DateTime? DateAdded { get; set; } = DateTime.UtcNow; 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/YoutubeMemberAccessToken.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | 3 | namespace Discord_Stream_Notify_Bot.DataBase.Table 4 | { 5 | public class YoutubeMemberAccessToken 6 | { 7 | [Key] 8 | public ulong DiscordUserId { get; set; } 9 | public string EncryptedAccessToken { get; set; } 10 | public DateTime? DateAdded { get; set; } = DateTime.Now; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/DataBase/Table/YoutubeMemberCheck.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.DataBase.Table 2 | { 3 | public class YoutubeMemberCheck : DbEntity 4 | { 5 | public ulong GuildId { get; set; } 6 | public ulong UserId { get; set; } 7 | public string CheckYTChannelId { get; set; } 8 | public DateTime LastCheckTime { get; set; } = DateTime.Now; 9 | public bool IsChecked { get; set; } = false; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Discord Stream Notify Bot.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net8.0 6 | Discord_Stream_Notify_Bot 7 | portable 8 | Debug;Release;Debug_DontRegisterCommand;Debug_API 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | all 28 | runtime; build; native; contentfiles; analyzers; buildtransitive 29 | 30 | 31 | all 32 | runtime; build; native; contentfiles; analyzers; buildtransitive 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | PreserveNewest 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | build$([System.DateTime]::UtcNow.AddHours(8).ToString("yyyy/MM/dd HH:mm:ss")) 63 | Logo_64.ico 64 | enable 65 | 66 | 67 | 68 | embedded 69 | 70 | 71 | 72 | embedded 73 | 74 | 75 | 76 | embedded 77 | 78 | 79 | 80 | embedded 81 | 82 | 83 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/HttpClients/DiscordWebhookClient.cs: -------------------------------------------------------------------------------- 1 | using System.Text; 2 | 3 | namespace Discord_Stream_Notify_Bot.HttpClients 4 | { 5 | public class DiscordWebhookClient 6 | { 7 | private readonly HttpClient _httpClient; 8 | private readonly DiscordSocketClient _client; 9 | private readonly BotConfig _botConfig; 10 | 11 | public DiscordWebhookClient(HttpClient httpClient, DiscordSocketClient client, BotConfig botConfig) 12 | { 13 | httpClient.DefaultRequestHeaders.Add("UserAgent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/96.0.4664.45 Safari/537.36"); 14 | _httpClient = httpClient; 15 | _client = client; 16 | _botConfig = botConfig; 17 | } 18 | 19 | public void SendMessageToDiscord(string content) 20 | { 21 | Message message = new Message(); 22 | 23 | if (_client.CurrentUser != null) 24 | { 25 | message.username = _client.CurrentUser.Username; 26 | message.avatar_url = _client.CurrentUser.GetAvatarUrl(); 27 | } 28 | else 29 | { 30 | message.username = "Bot"; 31 | message.avatar_url = ""; 32 | } 33 | 34 | message.content = content; 35 | var httpContent = new StringContent(JsonConvert.SerializeObject(message), Encoding.UTF8, "application/json"); 36 | _httpClient.PostAsync(_botConfig.WebHookUrl, httpContent); 37 | } 38 | 39 | class Message 40 | { 41 | public string username { get; set; } 42 | public string content { get; set; } 43 | public string avatar_url { get; set; } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/HttpClients/TwitCasting/TwitcastingClient.cs: -------------------------------------------------------------------------------- 1 | using Discord_Stream_Notify_Bot.HttpClients.Twitcasting.Model; 2 | using System.Text; 3 | 4 | #nullable enable 5 | 6 | namespace Discord_Stream_Notify_Bot.HttpClients 7 | { 8 | public class TwitcastingClient 9 | { 10 | private readonly HttpClient _httpClient; 11 | private readonly HttpClient? _apiHttpClient; 12 | 13 | private readonly string? _twitcastingAccessToken; 14 | private static readonly string[] _events = ["livestart"]; 15 | 16 | public TwitcastingClient(HttpClient httpClient, BotConfig botConfig) 17 | { 18 | _httpClient = httpClient; 19 | 20 | // https://apiv2-doc.twitcasting.tv/#access-token 21 | if (!string.IsNullOrEmpty(botConfig.TwitCastingClientId) && !string.IsNullOrEmpty(botConfig.TwitCastingClientSecret)) 22 | { 23 | _twitcastingAccessToken = Convert.ToBase64String(Encoding.ASCII.GetBytes($"{botConfig.TwitCastingClientId}:{botConfig.TwitCastingClientSecret}")); 24 | 25 | _apiHttpClient = new HttpClient(); 26 | _apiHttpClient.BaseAddress = new Uri("https://apiv2.twitcasting.tv/"); 27 | _apiHttpClient.DefaultRequestHeaders.Add("Accept", $"application/json"); 28 | _apiHttpClient.DefaultRequestHeaders.Add("Authorization", $"Basic {_twitcastingAccessToken}"); 29 | _apiHttpClient.DefaultRequestHeaders.Add("X-Api-Version", $"2.0"); 30 | } 31 | } 32 | 33 | /// 34 | /// 取得直播分類資料 35 | /// 36 | /// 37 | /// 38 | public async Task?> GetCategoriesAsync() 39 | { 40 | if (_apiHttpClient == null) 41 | throw new NullReferenceException(nameof(_apiHttpClient)); 42 | 43 | try 44 | { 45 | var json = await _apiHttpClient.GetStringAsync($"categories?lang=ja"); 46 | var data = JsonConvert.DeserializeObject(json); 47 | return data?.Categories; 48 | } 49 | catch (Exception ex) 50 | { 51 | Log.Error(ex.Demystify(), "TwitCastingClient.GetCategoriesAsync"); 52 | return null; 53 | } 54 | } 55 | 56 | /// 57 | /// 取得頻道正在直播的資料 58 | /// 59 | /// 60 | /// 如果正在直播,則回傳 (, 影片 Id),否則為 (, 0) 61 | public async Task GetNewStreamDataAsync(string channelId) 62 | { 63 | try 64 | { 65 | var json = await _httpClient.GetStringAsync($"https://twitcasting.tv/streamserver.php?target={channelId}&mode=client"); 66 | if (json == "{}") 67 | return null; 68 | 69 | var data = JsonConvert.DeserializeObject(json); 70 | return data; 71 | } 72 | catch (Exception ex) 73 | { 74 | Log.Error(ex.Demystify(), "TwitCastingClient.GetNewStreamDataAsync"); 75 | return null; 76 | } 77 | } 78 | 79 | // https://apiv2-doc.twitcasting.tv/#get-movie-info 80 | /// 81 | /// 取得影片資料 82 | /// 83 | /// 影片 Id 84 | /// 85 | public async Task GetMovieInfoAsync(int movieId) 86 | { 87 | if (_apiHttpClient == null) 88 | throw new NullReferenceException(nameof(_apiHttpClient)); 89 | 90 | if (movieId <= 0) 91 | throw new FormatException(nameof(movieId)); 92 | 93 | try 94 | { 95 | var json = await _apiHttpClient.GetStringAsync($"movies/{movieId}"); 96 | var data = JsonConvert.DeserializeObject(json); 97 | return data; 98 | } 99 | catch (Exception ex) 100 | { 101 | Log.Error(ex.Demystify(), "TwitCastingClient.GetMovieInfoAsync"); 102 | return null; 103 | } 104 | } 105 | 106 | /// 107 | /// 取得使用者資訊 108 | /// 109 | /// 使用者 id 或 screen_id 110 | /// 111 | public async Task GetUserInfoAsync(string userIdOrScreenId) 112 | { 113 | if (_apiHttpClient == null) 114 | throw new NullReferenceException(nameof(_apiHttpClient)); 115 | 116 | if (string.IsNullOrEmpty(userIdOrScreenId)) 117 | throw new ArgumentNullException(nameof(userIdOrScreenId)); 118 | 119 | try 120 | { 121 | var json = await _apiHttpClient.GetStringAsync($"users/{userIdOrScreenId}"); 122 | var data = JsonConvert.DeserializeObject(json); 123 | return data; 124 | } 125 | catch (HttpRequestException ex) when (ex.StatusCode == System.Net.HttpStatusCode.NotFound) 126 | { 127 | // Not Found 128 | return null; 129 | } 130 | catch (Exception ex) 131 | { 132 | Log.Error(ex.Demystify(), "TwitCastingClient.GetUserInfoAsync"); 133 | return null; 134 | } 135 | } 136 | 137 | #region WebHook 138 | /// 139 | /// 取得所有已註冊的 WebHook 140 | /// 141 | /// 返回 WebHook 列表 142 | /// 143 | public async Task?> GetAllRegistedWebHookAsync() 144 | { 145 | if (_apiHttpClient == null) 146 | throw new NullReferenceException(nameof(_apiHttpClient)); 147 | 148 | const int pageSize = 50; 149 | int offset = 0; 150 | int allCount = int.MaxValue; 151 | var result = new List(); 152 | 153 | try 154 | { 155 | while (result.Count < allCount) 156 | { 157 | var url = $"webhooks?limit={pageSize}&offset={offset}"; 158 | var jsonResponse = await _apiHttpClient.GetStringAsync(url); 159 | var data = JsonConvert.DeserializeObject(jsonResponse); 160 | 161 | if (data?.Webhooks == null || data.Webhooks.Count == 0) 162 | break; 163 | 164 | if (allCount == int.MaxValue) 165 | allCount = data.AllCount; 166 | 167 | result.AddRange(data.Webhooks); 168 | offset += pageSize; 169 | } 170 | 171 | return result; 172 | } 173 | catch (Exception ex) 174 | { 175 | Log.Error(ex.Demystify(), "TwitCastingClient.GetAllRegistedWebHookAsync"); 176 | return null; 177 | } 178 | } 179 | 180 | /// 181 | /// 註冊 WebHook 182 | /// 183 | /// 184 | /// 185 | /// 186 | public async Task RegisterWebHookAsync(string channelId) 187 | { 188 | if (_apiHttpClient == null) 189 | throw new NullReferenceException(nameof(_apiHttpClient)); 190 | 191 | if (string.IsNullOrEmpty(channelId)) 192 | throw new NullReferenceException(nameof(channelId)); 193 | 194 | try 195 | { 196 | var responseMessage = await _apiHttpClient.PostAsync("webhooks", new StringContent(JsonConvert.SerializeObject(new 197 | { 198 | user_id = channelId, 199 | events = _events 200 | }))); 201 | 202 | responseMessage.EnsureSuccessStatusCode(); 203 | 204 | return true; 205 | } 206 | catch (Exception ex) 207 | { 208 | Log.Error(ex.Demystify(), "TwitCastingClient.RegisterWebHookAsync"); 209 | return null; 210 | } 211 | } 212 | 213 | /// 214 | /// 取消註冊 WebHook 215 | /// 216 | /// 217 | /// 218 | /// 219 | public async Task RemoveWebHookAsync(string channelId) 220 | { 221 | if (_apiHttpClient == null) 222 | throw new NullReferenceException(nameof(_apiHttpClient)); 223 | 224 | if (string.IsNullOrEmpty(channelId)) 225 | throw new NullReferenceException(nameof(channelId)); 226 | 227 | try 228 | { 229 | var responseMessage = await _apiHttpClient.DeleteAsync($"webhooks?user_id={channelId}&events[]=livestart"); 230 | 231 | responseMessage.EnsureSuccessStatusCode(); 232 | 233 | return true; 234 | } 235 | catch (Exception ex) 236 | { 237 | Log.Error(ex.Demystify(), "TwitCastingClient.RemoveWebHookAsync"); 238 | return null; 239 | } 240 | } 241 | #endregion 242 | } 243 | } 244 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/HttpClients/Twitcasting/Model/CategoriesJson.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.HttpClients.Twitcasting.Model 2 | { 3 | public class Category 4 | { 5 | [JsonProperty("id")] 6 | public string Id { get; set; } 7 | 8 | [JsonProperty("name")] 9 | public string Name { get; set; } 10 | 11 | [JsonProperty("sub_categories")] 12 | public List SubCategories { get; set; } 13 | } 14 | 15 | public class CategoriesJson 16 | { 17 | [JsonProperty("categories")] 18 | public List Categories { get; set; } 19 | } 20 | 21 | public class SubCategory 22 | { 23 | [JsonProperty("id")] 24 | public string Id { get; set; } 25 | 26 | [JsonProperty("name")] 27 | public string Name { get; set; } 28 | 29 | [JsonProperty("count")] 30 | public int Count { get; set; } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/HttpClients/Twitcasting/Model/GetAllRegistedWebHookJson.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.HttpClients.Twitcasting.Model 2 | { 3 | public class GetAllRegistedWebHookJson 4 | { 5 | [JsonProperty("all_count")] 6 | public int AllCount { get; set; } 7 | 8 | [JsonProperty("webhooks")] 9 | public List Webhooks { get; set; } 10 | } 11 | 12 | public class Webhook 13 | { 14 | [JsonProperty("user_id")] 15 | public string UserId { get; set; } 16 | 17 | [JsonProperty("event")] 18 | public string Event { get; set; } 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/HttpClients/Twitcasting/Model/GetMovieInfoResponse.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.HttpClients.Twitcasting.Model 2 | { 3 | public class Broadcaster 4 | { 5 | [JsonProperty("id")] 6 | public string Id { get; set; } 7 | 8 | [JsonProperty("screen_id")] 9 | public string ScreenId { get; set; } 10 | 11 | [JsonProperty("name")] 12 | public string Name { get; set; } 13 | 14 | [JsonProperty("image")] 15 | public string Image { get; set; } 16 | 17 | [JsonProperty("profile")] 18 | public string Profile { get; set; } 19 | 20 | [JsonProperty("level")] 21 | public int Level { get; set; } 22 | 23 | [JsonProperty("last_movie_id")] 24 | public string LastMovieId { get; set; } 25 | 26 | [JsonProperty("is_live")] 27 | public bool IsLive { get; set; } 28 | 29 | [JsonProperty("supporter_count")] 30 | public int SupporterCount { get; set; } 31 | 32 | [JsonProperty("supporting_count")] 33 | public int SupportingCount { get; set; } 34 | 35 | [JsonProperty("created")] 36 | public int Created { get; set; } 37 | } 38 | 39 | public class Movie 40 | { 41 | [JsonProperty("id")] 42 | public string Id { get; set; } 43 | 44 | [JsonProperty("user_id")] 45 | public string UserId { get; set; } 46 | 47 | [JsonProperty("title")] 48 | public string Title { get; set; } 49 | 50 | [JsonProperty("subtitle")] 51 | public string Subtitle { get; set; } 52 | 53 | [JsonProperty("last_owner_comment")] 54 | public object LastOwnerComment { get; set; } 55 | 56 | [JsonProperty("category")] 57 | public string Category { get; set; } 58 | 59 | [JsonProperty("link")] 60 | public string Link { get; set; } 61 | 62 | [JsonProperty("is_live")] 63 | public bool IsLive { get; set; } 64 | 65 | [JsonProperty("is_recorded")] 66 | public bool IsRecorded { get; set; } 67 | 68 | [JsonProperty("comment_count")] 69 | public int CommentCount { get; set; } 70 | 71 | [JsonProperty("large_thumbnail")] 72 | public string LargeThumbnail { get; set; } 73 | 74 | [JsonProperty("small_thumbnail")] 75 | public string SmallThumbnail { get; set; } 76 | 77 | [JsonProperty("country")] 78 | public string Country { get; set; } 79 | 80 | [JsonProperty("duration")] 81 | public int Duration { get; set; } 82 | 83 | [JsonProperty("created")] 84 | public int Created { get; set; } 85 | 86 | [JsonProperty("is_collabo")] 87 | public bool IsCollabo { get; set; } 88 | 89 | [JsonProperty("is_protected")] 90 | public bool IsProtected { get; set; } 91 | 92 | [JsonProperty("max_view_count")] 93 | public int MaxViewCount { get; set; } 94 | 95 | [JsonProperty("current_view_count")] 96 | public int CurrentViewCount { get; set; } 97 | 98 | [JsonProperty("total_view_count")] 99 | public int TotalViewCount { get; set; } 100 | 101 | [JsonProperty("hls_url")] 102 | public object HlsUrl { get; set; } 103 | } 104 | 105 | public class GetMovieInfoResponse 106 | { 107 | [JsonProperty("movie")] 108 | public Movie Movie { get; set; } 109 | 110 | [JsonProperty("broadcaster")] 111 | public Broadcaster Broadcaster { get; set; } 112 | 113 | [JsonProperty("tags")] 114 | public List Tags { get; set; } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/HttpClients/Twitcasting/Model/GetUserInfoResponse.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Discord_Stream_Notify_Bot.HttpClients.Twitcasting.Model 4 | { 5 | public class GetUserInfoResponse 6 | { 7 | [JsonProperty("user")] 8 | public Broadcaster User { get; set; } 9 | 10 | [JsonProperty("supporter_count")] 11 | public int SupporterCount { get; set; } 12 | 13 | [JsonProperty("supporting_count")] 14 | public int SupportingCount { get; set; } 15 | } 16 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/HttpClients/Twitcasting/Model/TcBackendStreamData.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.HttpClients.Twitcasting.Model 2 | { 3 | public class TcBackendStreamData 4 | { 5 | [JsonProperty("movie")] 6 | public BackendMovie Movie { get; set; } 7 | 8 | [JsonProperty("hls")] 9 | public Hls Hls { get; set; } 10 | 11 | [JsonProperty("fmp4")] 12 | public Fmp4 Fmp4 { get; set; } 13 | 14 | [JsonProperty("llfmp4")] 15 | public Llfmp4 Llfmp4 { get; set; } 16 | 17 | [JsonProperty("webrtc")] 18 | public Webrtc Webrtc { get; set; } 19 | } 20 | 21 | public class Fmp4 22 | { 23 | [JsonProperty("host")] 24 | public string Host { get; set; } 25 | 26 | [JsonProperty("proto")] 27 | public string Proto { get; set; } 28 | 29 | [JsonProperty("source")] 30 | public bool Source { get; set; } 31 | 32 | [JsonProperty("mobilesource")] 33 | public bool Mobilesource { get; set; } 34 | } 35 | 36 | public class Hls 37 | { 38 | [JsonProperty("host")] 39 | public string Host { get; set; } 40 | 41 | [JsonProperty("proto")] 42 | public string Proto { get; set; } 43 | 44 | [JsonProperty("source")] 45 | public bool Source { get; set; } 46 | } 47 | 48 | public class Llfmp4 49 | { 50 | [JsonProperty("streams")] 51 | public Streams Streams { get; set; } 52 | } 53 | 54 | public class BackendMovie 55 | { 56 | [JsonProperty("id")] 57 | public int Id { get; set; } 58 | 59 | [JsonProperty("live")] 60 | public bool Live { get; set; } 61 | } 62 | 63 | public class Streams 64 | { 65 | [JsonProperty("main")] 66 | public string Main { get; set; } 67 | 68 | [JsonProperty("base")] 69 | public string Base { get; set; } 70 | } 71 | 72 | public class Webrtc 73 | { 74 | [JsonProperty("streams")] 75 | public Streams Streams { get; set; } 76 | 77 | [JsonProperty("app")] 78 | public App App { get; set; } 79 | } 80 | 81 | public class App 82 | { 83 | [JsonProperty("mode")] 84 | public string Mode { get; set; } 85 | 86 | [JsonProperty("url")] 87 | public string Url { get; set; } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/HttpClients/Twitter/TwitterSpacesData.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.HttpClients.Twitter 2 | { 3 | public class TwitterSpacesData 4 | { 5 | public string UserId { get; set; } 6 | public string SpaceId { get; set; } 7 | public string SpaceTitle { get; set; } 8 | public DateTime? StartAt { get; set; } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/HttpClients/Twitter/TwitterUserJson.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.HttpClients.Twitter 2 | { 3 | public class TwitterUserJson 4 | { 5 | [JsonProperty("data")] 6 | public Data Data { get; set; } 7 | } 8 | 9 | public class Data 10 | { 11 | [JsonProperty("user")] 12 | public User User { get; set; } 13 | } 14 | 15 | public class User 16 | { 17 | [JsonProperty("result")] 18 | public Result Result { get; set; } 19 | } 20 | 21 | public class Result 22 | { 23 | [JsonProperty("rest_id")] 24 | public string RestId { get; set; } 25 | 26 | [JsonProperty("legacy")] 27 | public Legacy Legacy { get; set; } 28 | } 29 | 30 | public class Legacy 31 | { 32 | [JsonProperty("protected")] 33 | public bool? Protected { get; set; } 34 | 35 | [JsonProperty("created_at")] 36 | public string CreatedAt { get; set; } 37 | 38 | [JsonProperty("default_profile")] 39 | public bool? DefaultProfile { get; set; } 40 | 41 | [JsonProperty("default_profile_image")] 42 | public bool? DefaultProfileImage { get; set; } 43 | 44 | [JsonProperty("description")] 45 | public string Description { get; set; } 46 | 47 | [JsonProperty("favourites_count")] 48 | public int? FavouritesCount { get; set; } 49 | 50 | [JsonProperty("followers_count")] 51 | public int? FollowersCount { get; set; } 52 | 53 | [JsonProperty("friends_count")] 54 | public int? FriendsCount { get; set; } 55 | 56 | [JsonProperty("has_custom_timelines")] 57 | public bool? HasCustomTimelines { get; set; } 58 | 59 | [JsonProperty("is_translator")] 60 | public bool? IsTranslator { get; set; } 61 | 62 | [JsonProperty("listed_count")] 63 | public int? ListedCount { get; set; } 64 | 65 | [JsonProperty("location")] 66 | public string Location { get; set; } 67 | 68 | [JsonProperty("media_count")] 69 | public int? MediaCount { get; set; } 70 | 71 | [JsonProperty("name")] 72 | public string Name { get; set; } 73 | 74 | [JsonProperty("normal_followers_count")] 75 | public int? NormalFollowersCount { get; set; } 76 | 77 | [JsonProperty("possibly_sensitive")] 78 | public bool? PossiblySensitive { get; set; } 79 | 80 | [JsonProperty("screen_name")] 81 | public string ScreenName { get; set; } 82 | 83 | [JsonProperty("profile_banner_url")] 84 | public string ProfileBannerUrl { get; set; } 85 | 86 | [JsonProperty("profile_image_url_https")] 87 | public string ProfileImageUrlHttps { get; set; } 88 | 89 | [JsonProperty("statuses_count")] 90 | public int? StatusesCount { get; set; } 91 | 92 | [JsonProperty("translator_type")] 93 | public string TranslatorType { get; set; } 94 | 95 | [JsonProperty("url")] 96 | public string Url { get; set; } 97 | 98 | [JsonProperty("verified")] 99 | public bool? Verified { get; set; } 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/Attribute/CommandExampleAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Interaction.Attribute 2 | { 3 | [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] 4 | sealed class CommandExampleAttribute : System.Attribute 5 | { 6 | readonly string[] expArray; 7 | 8 | public CommandExampleAttribute(params string[] expArray) 9 | { 10 | this.expArray = expArray; 11 | } 12 | 13 | public string[] ExpArray 14 | { 15 | get { return expArray; } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/Attribute/CommandSummaryAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Interaction.Attribute 2 | { 3 | [AttributeUsage(AttributeTargets.Method, Inherited = false, AllowMultiple = false)] 4 | sealed class CommandSummaryAttribute : System.Attribute 5 | { 6 | readonly string summary; 7 | 8 | public CommandSummaryAttribute(string summary) 9 | { 10 | this.summary = summary; 11 | } 12 | 13 | public string Summary 14 | { 15 | get { return summary; } 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/Attribute/RequireGuildAttribute.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | 3 | namespace Discord_Stream_Notify_Bot.Interaction.Attribute 4 | { 5 | public class RequireGuildAttribute : PreconditionAttribute 6 | { 7 | public RequireGuildAttribute(ulong gId) 8 | { 9 | GuildId = gId; 10 | } 11 | 12 | public ulong? GuildId { get; } 13 | 14 | public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services) 15 | { 16 | if (context.Guild.Id == GuildId) return Task.FromResult(PreconditionResult.FromSuccess()); 17 | else return Task.FromResult(PreconditionResult.FromError("此伺服器不可使用本指令")); 18 | } 19 | } 20 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/Attribute/RequireGuildMemberCountAttribute.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | 3 | namespace Discord_Stream_Notify_Bot.Interaction.Attribute 4 | { 5 | public class RequireGuildMemberCountAttribute : PreconditionAttribute 6 | { 7 | public RequireGuildMemberCountAttribute(uint gCount) 8 | { 9 | GuildMemberCount = gCount; 10 | } 11 | 12 | public uint? GuildMemberCount { get; } 13 | public override string ErrorMessage { get; } = "此伺服器不可使用本指令"; 14 | 15 | public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services) 16 | { 17 | if (context.Interaction.User.Id == Bot.ApplicatonOwner.Id) return Task.FromResult(PreconditionResult.FromSuccess()); 18 | 19 | if (Discord_Stream_Notify_Bot.Utility.OfficialGuildList.Contains(context.Guild.Id)) return Task.FromResult(PreconditionResult.FromSuccess()); 20 | 21 | var memberCount = ((SocketGuild)context.Guild).MemberCount; 22 | if (memberCount >= GuildMemberCount) return Task.FromResult(PreconditionResult.FromSuccess()); 23 | else return Task.FromResult(PreconditionResult.FromError($"此伺服器不可使用本指令\n" + 24 | $"指令要求伺服器人數: `{GuildMemberCount}` 人\n" + 25 | $"目前 Bot 所取得的伺服器人數: `{memberCount}` 人\n" + 26 | $"由於快取的關係,可能會遇到伺服器人數錯誤的問題\n" + 27 | $"如有任何需要請聯繫 Bot 擁有者處理 (你可使用 `/utility send-message-to-bot-owner` 對擁有者發送訊息)")); 28 | } 29 | } 30 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/Attribute/RequireGuildOwnerAttribute.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | 3 | namespace Discord_Stream_Notify_Bot.Interaction.Attribute 4 | { 5 | public class RequireGuildOwnerAttribute : PreconditionAttribute 6 | { 7 | public RequireGuildOwnerAttribute() 8 | { 9 | } 10 | 11 | public override string ErrorMessage { get; } = "非伺服器擁有者不可使用本指令"; 12 | 13 | public override Task CheckRequirementsAsync(IInteractionContext context, ICommandInfo commandInfo, IServiceProvider services) 14 | { 15 | if (context.Interaction.User.Id == Bot.ApplicatonOwner.Id) return Task.FromResult(PreconditionResult.FromSuccess()); 16 | 17 | if (context.Interaction.User.Id == context.Guild.OwnerId) return Task.FromResult(PreconditionResult.FromSuccess()); 18 | else return Task.FromResult(PreconditionResult.FromError("非伺服器擁有者不可使用本指令")); 19 | } 20 | } 21 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/CommonEqualityComparer.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Interaction 2 | { 3 | public class CommonEqualityComparer : IEqualityComparer 4 | { 5 | private Func keySelector; 6 | 7 | public CommonEqualityComparer(Func keySelector) 8 | { 9 | this.keySelector = keySelector; 10 | } 11 | 12 | public bool Equals(T x, T y) 13 | { 14 | return EqualityComparer.Default.Equals(keySelector(x), keySelector(y)); 15 | } 16 | 17 | public int GetHashCode(T obj) 18 | { 19 | return EqualityComparer.Default.GetHashCode(keySelector(obj)); 20 | } 21 | } 22 | 23 | } 24 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/Help/Help.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace Discord_Stream_Notify_Bot.Interaction.Help 5 | { 6 | [Group("help", "說明")] 7 | public class Help : TopLevelModule 8 | { 9 | private readonly InteractionService _interaction; 10 | private readonly IServiceProvider _services; 11 | 12 | public Help(InteractionService interaction, IServiceProvider service) 13 | { 14 | _interaction = interaction; 15 | _services = service; 16 | } 17 | 18 | public class HelpGetModulesAutocompleteHandler : AutocompleteHandler 19 | { 20 | public override async Task GenerateSuggestionsAsync(IInteractionContext context, IAutocompleteInteraction autocompleteInteraction, IParameterInfo parameter, IServiceProvider services) 21 | { 22 | List results = new(); 23 | var succ = new HashSet((await Task.WhenAll(services.GetService().SlashCommands.Select(async x => 24 | { 25 | var pre = await x.CheckPreconditionsAsync(context, services).ConfigureAwait(false); 26 | return (Cmd: x, Succ: pre.IsSuccess); 27 | })).ConfigureAwait(false)) 28 | .Where(x => x.Succ) 29 | .Select(x => x.Cmd)); 30 | 31 | try 32 | { 33 | foreach (var item in succ.GroupBy((x) => x.Module.Name)) 34 | { 35 | var module = item.First().Module; 36 | results.Add(new AutocompleteResult((string.IsNullOrWhiteSpace(module.Description) ? "" : module.Description + " ") + $"({module.Name})", module.Name)); 37 | } 38 | } 39 | catch (Exception ex) 40 | { 41 | Log.Error("HelpGetModulesAutocompleteHandler"); 42 | Log.Error(ex.ToString()); 43 | } 44 | 45 | return AutocompletionResult.FromSuccess(results.Take(25)); 46 | } 47 | } 48 | 49 | [SlashCommand("get-all-modules", "顯示全部模組")] 50 | public async Task Modules() 51 | { 52 | var succ = new HashSet((await Task.WhenAll(_interaction.SlashCommands.Select(async x => 53 | { 54 | var pre = await x.CheckPreconditionsAsync(Context, _services).ConfigureAwait(false); 55 | return (Cmd: x, Succ: pre.IsSuccess); 56 | })).ConfigureAwait(false)) 57 | .Where(x => x.Succ) 58 | .Select(x => x.Cmd)); 59 | 60 | await RespondAsync(embed: new EmbedBuilder().WithOkColor().WithTitle("模組清單") 61 | .WithDescription(string.Join("\n", succ.GroupBy((x) => x.Module.Name).Select((x) => "。" + x.Key))) 62 | .WithFooter("輸入 `/help getallcommands 模組名稱` 以顯示模組內全部的指令,例 `/help get-all-commands help`") 63 | .Build()); 64 | } 65 | 66 | [SlashCommand("get-all-commands", "顯示模組內包含的指令")] 67 | public async Task Commands([Summary("模組名稱"), Autocomplete(typeof(HelpGetModulesAutocompleteHandler))] string module) 68 | { 69 | module = module?.Trim(); 70 | if (string.IsNullOrWhiteSpace(module)) 71 | { 72 | await Context.Interaction.SendErrorAsync("未輸入模組名稱"); 73 | return; 74 | } 75 | 76 | var cmds = _interaction.SlashCommands.Where(c => c.Module.Name.ToUpperInvariant() == module.ToUpperInvariant()).OrderBy(c => c.Name).Distinct(new CommandTextEqualityComparer()); 77 | if (cmds.Count() == 0) { await Context.Interaction.SendErrorAsync($"找不到 {module} 模組", ephemeral: true); return; } 78 | 79 | var succ = new HashSet((await Task.WhenAll(cmds.Select(async x => 80 | { 81 | var pre = await x.CheckPreconditionsAsync(Context, _services).ConfigureAwait(false); 82 | return (Cmd: x, Succ: pre.IsSuccess); 83 | })).ConfigureAwait(false)) 84 | .Where(x => x.Succ) 85 | .Select(x => x.Cmd)); 86 | cmds = cmds.Where(x => succ.Contains(x)); 87 | 88 | if (cmds.Count() == 0) 89 | { 90 | await Context.Interaction.SendErrorAsync(module + " 未包含你可使用的指令"); 91 | return; 92 | } 93 | 94 | var embed = new EmbedBuilder().WithOkColor().WithTitle($"{cmds.First().Module.Name} 內包含的指令").WithFooter("輸入 `/help get-command-help 指令` 以顯示指令的詳細說明,例 `/help get-command-help add-youtube-notice`"); 95 | var commandList = new List(); 96 | 97 | foreach (var item in cmds) 98 | { 99 | var str = string.Format($"**`/{cmds.First().Module.SlashGroupName} {item.Name}`**"); 100 | if (!commandList.Contains(str)) commandList.Add(str); 101 | } 102 | embed.WithDescription(string.Join('\n', commandList)); 103 | 104 | await RespondAsync(embed: embed.Build()); 105 | } 106 | 107 | [SlashCommand("get-command-help", "顯示指令的詳細說明")] 108 | public async Task H([Summary("模組名稱"), Autocomplete(typeof(HelpGetModulesAutocompleteHandler))] string module = "", [Summary("指令名稱")] string command = "") 109 | { 110 | command = command?.Trim(); 111 | 112 | if (string.IsNullOrWhiteSpace(module)) 113 | { 114 | EmbedBuilder embed = new EmbedBuilder().WithOkColor().WithFooter("輸入 `/help get-all-modules` 取得所有的模組"); 115 | embed.Title = "直播小幫手 建置版本" + Program.Version; 116 | #if DEBUG || DEBUG_DONTREGISTERCOMMAND 117 | embed.Title += " (測試版)"; 118 | #endif 119 | embed.WithDescription(File.ReadAllText(Discord_Stream_Notify_Bot.Utility.GetDataFilePath("HelpDescription.txt")).Replace("\\n", "\n") + 120 | $"\n\n您可以透過 {Format.Url("綠界", Discord_Stream_Notify_Bot.Utility.ECPayUrl)} 或 {Format.Url("Paypal", Discord_Stream_Notify_Bot.Utility.PaypalUrl)} 來贊助直播小幫手"); 121 | await RespondAsync(embed: embed.Build()); 122 | return; 123 | } 124 | 125 | var cmds = _interaction.SlashCommands.Where(c => c.Module.Name.ToUpperInvariant() == module.ToUpperInvariant()).OrderBy(c => c.Name).Distinct(new CommandTextEqualityComparer()); 126 | if (cmds.Count() == 0) 127 | { 128 | await Context.Interaction.SendErrorAsync($"找不到 {module} 模組\n輸入 `/help get-all-modules` 取得所有的模組", ephemeral: true); 129 | return; 130 | } 131 | 132 | SlashCommandInfo commandInfo = cmds.FirstOrDefault((x) => x.Name == command.ToLowerInvariant()); 133 | if (commandInfo == null) 134 | { 135 | await Context.Interaction.SendErrorAsync($"找不到 {command} 指令"); 136 | return; 137 | } 138 | 139 | await RespondAsync(embed: _service.GetCommandHelp(commandInfo).Build()); 140 | } 141 | } 142 | 143 | public class CommandTextEqualityComparer : IEqualityComparer 144 | { 145 | public bool Equals(SlashCommandInfo x, SlashCommandInfo y) => x.Name == y.Name; 146 | 147 | public int GetHashCode(SlashCommandInfo obj) => obj.Name.GetHashCode(StringComparison.InvariantCulture); 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/Help/Service/HelpService.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | using Discord_Stream_Notify_Bot.Interaction.Attribute; 3 | 4 | namespace Discord_Stream_Notify_Bot.Interaction.Help.Service 5 | { 6 | public class HelpService : IInteractionService 7 | { 8 | public EmbedBuilder GetCommandHelp(SlashCommandInfo com) 9 | { 10 | var str = string.Format($"**`/{com.Name}`**"); 11 | var des = com.Description; 12 | if (com.Attributes.Any((x) => x is CommandSummaryAttribute)) 13 | { 14 | var att = com.Attributes.FirstOrDefault((x) => x is CommandSummaryAttribute) as CommandSummaryAttribute; 15 | des = att.Summary; 16 | } 17 | var em = new EmbedBuilder().WithTitle(com.Name).WithDescription(des); 18 | 19 | if (com.Parameters.Count > 0) 20 | { 21 | string par = ""; 22 | foreach (var item in com.Parameters) 23 | par += item.Name + " " + item.Description + "\n"; 24 | em.AddField("參數", par.TrimEnd('\n')); 25 | } 26 | 27 | var reqs = GetCommandRequirements(com); 28 | if (reqs.Any()) em.AddField("指令執行者權限要求", string.Join("\n", reqs)); 29 | 30 | var botReqs = GetBotCommandRequirements(com); 31 | if (botReqs.Any()) em.AddField("Bot權限要求", string.Join("\n", botReqs)); 32 | 33 | var exp = GetCommandExampleString(com); 34 | if (!string.IsNullOrEmpty(exp)) em.AddField("例子", exp); 35 | 36 | em.WithFooter(efb => efb.WithText("模組: " + com.Module.Name)) 37 | .WithOkColor(); 38 | 39 | return em; 40 | } 41 | 42 | public static string[] GetCommandRequirements(SlashCommandInfo cmd) => 43 | cmd.Preconditions 44 | .Where(ca => ca is RequireOwnerAttribute || ca is RequireUserPermissionAttribute) 45 | .Select(ca => 46 | { 47 | if (ca is RequireOwnerAttribute) 48 | { 49 | return "Bot擁有者限定"; 50 | } 51 | 52 | var cau = (RequireUserPermissionAttribute)ca; 53 | if (cau.GuildPermission != null) 54 | { 55 | return ("伺服器 " + cau.GuildPermission.ToString() + " 權限") 56 | .Replace("Guild", "Server", StringComparison.InvariantCulture); 57 | } 58 | 59 | return ("頻道 " + cau.ChannelPermission + " 權限") 60 | .Replace("Guild", "Server", StringComparison.InvariantCulture); 61 | }) 62 | .ToArray(); 63 | 64 | public static string[] GetBotCommandRequirements(SlashCommandInfo cmd) => 65 | cmd.Preconditions 66 | .Where(ca => ca is RequireBotPermissionAttribute) 67 | .Select(ca => 68 | { 69 | var cau = (RequireBotPermissionAttribute)ca; 70 | if (cau.GuildPermission != null) 71 | { 72 | return ("伺服器 " + cau.GuildPermission.ToString() + " 權限") 73 | .Replace("Guild", "Server", StringComparison.InvariantCulture); 74 | } 75 | 76 | return ("頻道 " + cau.ChannelPermission + " 權限") 77 | .Replace("Guild", "Server", StringComparison.InvariantCulture); 78 | }) 79 | .ToArray(); 80 | 81 | public static string GetCommandExampleString(SlashCommandInfo cmd) 82 | { 83 | var att = cmd.Attributes.FirstOrDefault((x) => x is CommandExampleAttribute); 84 | if (att == null) return ""; 85 | 86 | var commandExampleAttribute = att as CommandExampleAttribute; 87 | 88 | return string.Join("\n", commandExampleAttribute.ExpArray 89 | .Select((x) => $"`/{cmd.Module.SlashGroupName} {cmd.Name} {x}`") 90 | .ToArray()); 91 | } 92 | } 93 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/IInteractionService.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Interaction 2 | { 3 | public interface IInteractionService 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/InteractionHandler.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | using System.Reflection; 3 | 4 | #if DEBUG || DEBUG_DONTREGISTERCOMMAND 5 | using System.Text.RegularExpressions; 6 | #endif 7 | 8 | namespace Discord_Stream_Notify_Bot.Interaction 9 | { 10 | class InteractionHandler : IInteractionService 11 | { 12 | private readonly DiscordSocketClient _client; 13 | private readonly InteractionService _interactions; 14 | private readonly IServiceProvider _services; 15 | 16 | public int CommandCount => _interactions.ComponentCommands.Count + _interactions.ContextCommands.Count + _interactions.SlashCommands.Count; 17 | 18 | public InteractionHandler(IServiceProvider services, InteractionService interactions, DiscordSocketClient client) 19 | { 20 | _client = client; 21 | _interactions = interactions; 22 | _services = services; 23 | } 24 | 25 | public async Task InitializeAsync() 26 | { 27 | await _interactions.AddModulesAsync( 28 | assembly: Assembly.GetEntryAssembly(), 29 | services: _services); 30 | 31 | #region 檢查指令是否符合 Discord 的 Regex 規範 32 | #if DEBUG || DEBUG_DONTREGISTERCOMMAND 33 | bool isError = false; 34 | Regex regex = new Regex(@"^[\w-]{1,32}$"); 35 | var list = _interactions.Modules.Select(module => module.SlashCommands.Select((x) => new KeyValuePair>(x.Name, x.Parameters.Select((x2) => x2.Name).ToList()))); 36 | foreach (var item in list) 37 | { 38 | foreach (var item2 in item) 39 | { 40 | if (!regex.IsMatch(item2.Key)) 41 | { 42 | isError = true; 43 | Log.Error(item2.Key); 44 | continue; 45 | } 46 | 47 | foreach (var item3 in item2.Value) 48 | { 49 | if (!regex.IsMatch(item3)) 50 | { 51 | isError = true; 52 | Log.Error($"{item2.Key}: {item3}"); 53 | } 54 | } 55 | } 56 | } 57 | if (isError) Environment.Exit(3); 58 | #endif 59 | #endregion 60 | 61 | _client.InteractionCreated += (slash) => { var _ = Task.Run(() => HandleInteraction(slash)); return Task.CompletedTask; }; 62 | _interactions.SlashCommandExecuted += SlashCommandExecuted; 63 | } 64 | 65 | private async Task HandleInteraction(SocketInteraction arg) 66 | { 67 | try 68 | { 69 | var ctx = new SocketInteractionContext(_client, arg); 70 | await _interactions.ExecuteCommandAsync(ctx, _services); 71 | } 72 | catch (Exception ex) 73 | { 74 | Console.WriteLine(ex); 75 | 76 | // If a Slash Command execution fails it is most likely that the original interaction acknowledgement will persist. It is a good idea to delete the original 77 | // response, or at least let the user know that something went wrong during the command execution. 78 | if (arg.Type == InteractionType.ApplicationCommand) 79 | await arg.GetOriginalResponseAsync().ContinueWith(async (msg) => await msg.Result.DeleteAsync()); 80 | } 81 | } 82 | 83 | private async Task SlashCommandExecuted(SlashCommandInfo arg1, IInteractionContext arg2, IResult arg3) 84 | { 85 | string slashCommand = $"/{arg1}"; 86 | var commandData = arg2.Interaction.Data as SocketSlashCommandData; 87 | if (commandData.Options.Count > 0) slashCommand += GetOptionsValue(commandData.Options.First()); 88 | 89 | if (arg3.IsSuccess) 90 | { 91 | Log.Info($"[{arg2.Guild.Name}/{arg2.Channel.Name}] {arg2.User.Username} 執行 `{slashCommand}`"); 92 | } 93 | else 94 | { 95 | Log.Error($"[{arg2.Guild.Name}/{arg2.Channel.Name}] {arg2.User.Username} 執行 `{slashCommand}` 發生錯誤\r\n{arg3.ErrorReason}"); 96 | switch (arg3.Error) 97 | { 98 | case InteractionCommandError.UnmetPrecondition: 99 | await arg2.Interaction.SendErrorAsync(arg3.ErrorReason); 100 | break; 101 | case InteractionCommandError.UnknownCommand: 102 | await arg2.Interaction.SendErrorAsync("未知的指令,也許被移除或變更了"); 103 | break; 104 | case InteractionCommandError.BadArgs: 105 | await arg2.Interaction.SendErrorAsync("輸入的參數錯誤"); 106 | break; 107 | case InteractionCommandError.Exception when arg3.ErrorReason.Contains("50001"): 108 | await arg2.Interaction.SendErrorAsync($"我在 `{arg2.Channel}` 沒有 `讀取 & 編輯頻道 & 嵌入連結` 的權限,請給予權限後再次執行本指令"); 109 | break; 110 | default: 111 | await arg2.Interaction.SendErrorAsync("未知的錯誤,請向 Bot 擁有者回報"); 112 | break; 113 | } 114 | } 115 | } 116 | 117 | private string GetOptionsValue(SocketSlashCommandDataOption socketSlashCommandDataOption) 118 | { 119 | try 120 | { 121 | if (socketSlashCommandDataOption.Type != ApplicationCommandOptionType.SubCommand && socketSlashCommandDataOption.Type != ApplicationCommandOptionType.SubCommandGroup && !socketSlashCommandDataOption.Options.Any()) 122 | return $" {socketSlashCommandDataOption.Value}"; 123 | 124 | if (socketSlashCommandDataOption.Type == ApplicationCommandOptionType.SubCommand || socketSlashCommandDataOption.Type == ApplicationCommandOptionType.SubCommandGroup) GetOptionsValue(socketSlashCommandDataOption.Options.First()); 125 | return " " + string.Join(' ', socketSlashCommandDataOption.Options.Select(option => option.Value)); 126 | } 127 | catch (Exception) 128 | { 129 | return ""; 130 | } 131 | } 132 | } 133 | } 134 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/OwnerOnly/SendMsgToAllGuild.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | using Discord_Stream_Notify_Bot.Interaction.Attribute; 3 | using static Discord_Stream_Notify_Bot.Interaction.OwnerOnly.Service.SendMsgToAllGuildService; 4 | 5 | namespace Discord_Stream_Notify_Bot.Interaction.OwnerOnly 6 | { 7 | [DontAutoRegister] 8 | [RequireGuild(506083124015398932)] 9 | [DefaultMemberPermissions(GuildPermission.Administrator)] 10 | public class SendMsgToAllGuild : TopLevelModule 11 | { 12 | [SlashCommand("send-message", "傳送訊息到所有伺服器")] 13 | [RequireOwner] 14 | [DefaultMemberPermissions(GuildPermission.Administrator)] 15 | public async Task SendMessageToAllGuildAsync(NoticeType noticeType, Attachment attachment = null) 16 | { 17 | var mb = new ModalBuilder() 18 | .WithTitle("傳送全球訊息") 19 | .WithCustomId("send_message") 20 | .AddTextInput("發送類型", "notice_type", placeholder: "一般 or 工商", value: noticeType == NoticeType.Normal ? "一般" : "工商", minLength: 2, maxLength: 2, required: true) 21 | .AddTextInput("圖片網址", "image_url", placeholder: "https://...", value: attachment?.Url, required: false) 22 | .AddTextInput("訊息", "message", TextInputStyle.Paragraph, "內容...", required: true); 23 | 24 | await Context.Interaction.RespondWithModalAsync(mb.Build()); 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/ReactionEventWrapper.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Interaction 2 | { 3 | public sealed class ReactionEventWrapper : IDisposable 4 | { 5 | public IUserMessage Message { get; } 6 | public event Action OnReactionAdded = delegate { }; 7 | public event Action OnReactionRemoved = delegate { }; 8 | public event Action OnReactionsCleared = delegate { }; 9 | 10 | public ReactionEventWrapper(DiscordSocketClient client, IUserMessage msg) 11 | { 12 | Message = msg ?? throw new ArgumentNullException(nameof(msg)); 13 | _client = client; 14 | 15 | _client.ReactionAdded += Discord_ReactionAdded; 16 | _client.ReactionRemoved += Discord_ReactionRemoved; 17 | _client.ReactionsCleared += Discord_ReactionsCleared; 18 | } 19 | 20 | private Task Discord_ReactionsCleared(Cacheable user, Cacheable channel) 21 | { 22 | Task.Run(async () => 23 | { 24 | try 25 | { 26 | if ((await user.GetOrDownloadAsync()).Id == Message.Id) 27 | OnReactionsCleared?.Invoke(); 28 | } 29 | catch { } 30 | }); 31 | 32 | return Task.CompletedTask; 33 | } 34 | 35 | private Task Discord_ReactionRemoved(Cacheable user, Cacheable channel, SocketReaction reaction) 36 | { 37 | Task.Run(async () => 38 | { 39 | try 40 | { 41 | if ((await user.GetOrDownloadAsync()).Id == Message.Id) 42 | OnReactionRemoved?.Invoke(reaction); 43 | } 44 | catch { } 45 | }); 46 | 47 | return Task.CompletedTask; 48 | } 49 | 50 | private Task Discord_ReactionAdded(Cacheable user, Cacheable channel, SocketReaction reaction) 51 | { 52 | Task.Run(async () => 53 | { 54 | try 55 | { 56 | if ((await user.GetOrDownloadAsync()).Id == Message.Id) 57 | OnReactionAdded?.Invoke(reaction); 58 | } 59 | catch { } 60 | }); 61 | 62 | return Task.CompletedTask; 63 | } 64 | 65 | public void UnsubAll() 66 | { 67 | _client.ReactionAdded -= Discord_ReactionAdded; 68 | _client.ReactionRemoved -= Discord_ReactionRemoved; 69 | _client.ReactionsCleared -= Discord_ReactionsCleared; 70 | OnReactionAdded = null; 71 | OnReactionRemoved = null; 72 | OnReactionsCleared = null; 73 | } 74 | 75 | private bool disposing = false; 76 | private readonly DiscordSocketClient _client; 77 | 78 | public void Dispose() 79 | { 80 | if (disposing) 81 | return; 82 | disposing = true; 83 | UnsubAll(); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/TopLevelModule.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | using Discord_Stream_Notify_Bot.DataBase; 3 | 4 | namespace Discord_Stream_Notify_Bot.Interaction 5 | { 6 | public abstract class TopLevelModule : InteractionModuleBase 7 | { 8 | public async Task PromptUserConfirmAsync(string context) 9 | { 10 | string guid = Guid.NewGuid().ToString().Replace("-", ""); 11 | 12 | EmbedBuilder embed = new EmbedBuilder() 13 | .WithOkColor() 14 | .WithDescription(context) 15 | .WithFooter("10 秒後按鈕會無效化,請快速選擇或重新觸發"); 16 | 17 | ComponentBuilder component = new ComponentBuilder() 18 | .WithButton("是", $"{guid}-yes", ButtonStyle.Success) 19 | .WithButton("否", $"{guid}-no", ButtonStyle.Danger); 20 | 21 | await FollowupAsync(embed: embed.Build(), components: component.Build(), ephemeral: true).ConfigureAwait(false); 22 | 23 | try 24 | { 25 | var input = await GetUserClickAsync(Context.User.Id, Context.Channel.Id, guid).ConfigureAwait(false); 26 | return input; 27 | } 28 | finally 29 | { 30 | } 31 | } 32 | 33 | public async Task GetUserClickAsync(ulong userId, ulong channelId, string guid) 34 | { 35 | var userInputTask = new TaskCompletionSource(); 36 | 37 | try 38 | { 39 | Context.Client.ButtonExecuted += ButtonExecuted; 40 | 41 | if ((await Task.WhenAny(userInputTask.Task, Task.Delay(5000)).ConfigureAwait(false)) != userInputTask.Task) 42 | { 43 | return false; 44 | } 45 | 46 | return await userInputTask.Task.ConfigureAwait(false); 47 | } 48 | finally 49 | { 50 | Context.Client.ButtonExecuted -= ButtonExecuted; 51 | } 52 | 53 | Task ButtonExecuted(SocketMessageComponent component) 54 | { 55 | var _ = Task.Run(async () => 56 | { 57 | if (!component.Data.CustomId.StartsWith(guid)) 58 | return Task.CompletedTask; 59 | 60 | if (!(component is SocketMessageComponent userMsg) || 61 | userMsg.User.Id != userId || 62 | userMsg.Channel.Id != channelId) 63 | { 64 | await component.SendErrorAsync("你無法使用本功能", true).ConfigureAwait(false); 65 | return Task.CompletedTask; 66 | } 67 | 68 | userInputTask.TrySetResult(component.Data.CustomId.EndsWith("yes")); 69 | 70 | await component.UpdateAsync((x) => x.Components = new ComponentBuilder() 71 | .WithButton("是", $"{guid}-yes", ButtonStyle.Success, disabled: true) 72 | .WithButton("否", $"{guid}-no", ButtonStyle.Danger, disabled: true).Build()) 73 | .ConfigureAwait(false); 74 | return Task.CompletedTask; 75 | }); 76 | return Task.CompletedTask; 77 | } 78 | } 79 | 80 | public async Task CheckIsFirstSetNoticeAndSendWarningMessageAsync(MainDbContext dbContext) 81 | { 82 | bool firstCheck = !dbContext.NoticeYoutubeStreamChannel.AsNoTracking().Any((x) => x.GuildId == Context.Guild.Id); 83 | bool secondCheck = !dbContext.NoticeTwitchStreamChannels.AsNoTracking().Any((x) => x.GuildId == Context.Guild.Id); 84 | bool thirdCheck = !dbContext.GuildConfig.AsNoTracking().Any((x) => x.GuildId == Context.Guild.Id && x.LogMemberStatusChannelId != 0); 85 | if (firstCheck && secondCheck && thirdCheck) 86 | { 87 | await Context.Interaction.SendConfirmAsync("看來是第一次設定通知呢\n" + 88 | "請注意 Bot 擁有者會透過通知頻道發送工商或是小幫手相關的通知 (功能更新之類的)\n" + 89 | "你可以透過 `/utility set-global-notice-channel` 來設定由哪個頻道來接收小幫手相關的通知\n" + 90 | "而工商相關通知則會直接發送到此頻道上\n" + 91 | "(已認可的官方群組不會收到工商通知,如需添加認可或確認請向 Bot 擁有者詢問)\n" + 92 | "(你可使用 `/utility send-message-to-bot-owner` 對 Bot 擁有者發送訊息)", true, true); 93 | } 94 | } 95 | } 96 | 97 | public abstract class TopLevelModule : TopLevelModule where TService : IInteractionService 98 | { 99 | protected TopLevelModule() 100 | { 101 | } 102 | 103 | public TService _service { get; set; } 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/Utility/Service/UtilityService.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.Interaction.Utility.Service 2 | { 3 | public class UtilityService : IInteractionService 4 | { 5 | public UtilityService(DiscordSocketClient client) 6 | { 7 | client.ModalSubmitted += async modal => 8 | { 9 | switch (modal.Data.CustomId) 10 | { 11 | case "send-message-to-bot-owner": 12 | { 13 | await modal.DeferAsync(true); 14 | 15 | List components = modal.Data.Components.ToList(); 16 | string message = components.First(x => x.CustomId == "message").Value; 17 | string contactMethod = components.First(x => x.CustomId == "contact-method").Value; 18 | 19 | var embedBuilder = new EmbedBuilder() 20 | .WithOkColor() 21 | .WithTitle("新的使用者訊息") 22 | .WithAuthor(modal.User) 23 | .AddField("訊息", message) 24 | .AddField("聯繫方式", contactMethod); 25 | 26 | var componentBuilder = new ComponentBuilder() 27 | .WithButton("發送回覆", $"send-reply-to-user:{modal.User.Id}", ButtonStyle.Success); 28 | 29 | await Bot.ApplicatonOwner.SendMessageAsync(embed: embedBuilder.Build(), components: componentBuilder.Build()); 30 | 31 | embedBuilder 32 | .WithTitle("") 33 | .WithDescription($"已收到訊息,請確保你填寫的聯絡資訊可讓 Bot 擁有者聯繫\n" + 34 | $"注意: Bot 擁有者會優先透過 Bot 來回應你的訊息,請確保你已開啟與本 Bot 共通伺服器的 `私人訊息` 隱私設定"); 35 | 36 | await modal.FollowupAsync(embed: embedBuilder.Build(), ephemeral: true); 37 | } 38 | break; 39 | case "send-reply-to-user": 40 | { 41 | await modal.DeferAsync(true); 42 | 43 | List components = modal.Data.Components.ToList(); 44 | ulong userId = ulong.Parse(components.First(x => x.CustomId == "userId").Value); 45 | string message = components.First(x => x.CustomId == "message").Value; 46 | 47 | try 48 | { 49 | await (await client.Rest.GetUserAsync(userId)) 50 | .SendMessageAsync(embed: new EmbedBuilder() 51 | .WithOkColor() 52 | .WithTitle("來自擁有者的回覆") 53 | .WithDescription(message) 54 | .Build()); 55 | 56 | await modal.SendConfirmAsync($"發送成功\n" + 57 | $"回覆訊息: {message}", true, true); 58 | } 59 | catch (Discord.Net.HttpException httpEx) when (httpEx.DiscordCode == DiscordErrorCode.CannotSendMessageToUser) 60 | { 61 | await modal.SendErrorAsync("無法發送訊息,該使用者未開放私人訊息", true, true); 62 | return; 63 | } 64 | catch (Exception ex) 65 | { 66 | await modal.SendErrorAsync($"無法發送訊息: {ex}", true, true); 67 | return; 68 | } 69 | } 70 | break; 71 | default: 72 | break; 73 | } 74 | }; 75 | 76 | client.ButtonExecuted += async button => 77 | { 78 | if (button.HasResponded) 79 | return; 80 | 81 | if (!button.Data.CustomId.StartsWith("send-reply-to-user")) 82 | return; 83 | 84 | string userId = button.Data.CustomId.Split(':')[1]; 85 | var modalBuilder = new ModalBuilder().WithTitle("回覆訊息給使用者") 86 | .WithCustomId("send-reply-to-user") 87 | .AddTextInput("UserId", "userId", TextInputStyle.Short, "", null, null, true, userId) 88 | .AddTextInput("訊息", "message", TextInputStyle.Paragraph, "請輸入你要發送的訊息", null, null, true); 89 | 90 | await button.RespondWithModalAsync(modalBuilder.Build()); 91 | }; 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Interaction/Utility/Utility.cs: -------------------------------------------------------------------------------- 1 | using Discord.Interactions; 2 | using Discord_Stream_Notify_Bot.DataBase; 3 | using Discord_Stream_Notify_Bot.Interaction.Utility.Service; 4 | 5 | namespace Discord_Stream_Notify_Bot.Interaction.Utility 6 | { 7 | [Group("utility", "工具")] 8 | public class Utility : TopLevelModule 9 | { 10 | private readonly DiscordSocketClient _client; 11 | private readonly HttpClients.DiscordWebhookClient _discordWebhookClient; 12 | private readonly MainDbService _dbService; 13 | 14 | public Utility(DiscordSocketClient client, HttpClients.DiscordWebhookClient discordWebhookClient, MainDbService dbService) 15 | { 16 | _client = client; 17 | _discordWebhookClient = discordWebhookClient; 18 | _dbService = dbService; 19 | } 20 | 21 | [SlashCommand("ping", "延遲檢測")] 22 | public async Task PingAsync() 23 | { 24 | await Context.Interaction.SendConfirmAsync(":ping_pong: " + _client.Latency.ToString() + "ms"); 25 | } 26 | 27 | [SlashCommand("invite", "取得邀請連結")] 28 | public async Task InviteAsync() 29 | { 30 | #if RELEASE 31 | if (Context.User.Id != Bot.ApplicatonOwner.Id) 32 | { 33 | _discordWebhookClient.SendMessageToDiscord($"[{Context.Guild.Name}-{Context.Channel.Name}] {Context.User.Username}:({Context.User.Id}) 使用了邀請指令"); 34 | } 35 | #endif 36 | await Context.Interaction.SendConfirmAsync("", ephemeral: true); 37 | } 38 | 39 | [SlashCommand("status", "顯示機器人目前的狀態")] 40 | public async Task StatusAsync() 41 | { 42 | EmbedBuilder embedBuilder = new EmbedBuilder().WithOkColor(); 43 | embedBuilder.WithTitle("直播小幫手"); 44 | 45 | #if DEBUG || DEBUG_DONTREGISTERCOMMAND 46 | embedBuilder.Title += " (測試版)"; 47 | #endif 48 | 49 | embedBuilder.WithDescription($"建置版本 {Program.Version}"); 50 | embedBuilder.AddField("作者", "孤之界 (konnokai)", true); 51 | embedBuilder.AddField("擁有者", $"{Bot.ApplicatonOwner}", true); 52 | embedBuilder.AddField("狀態", $"伺服器 {_client.Guilds.Count}\n服務成員數 {_client.Guilds.Sum((x) => x.MemberCount)}", false); 53 | embedBuilder.AddField("看過的直播數量", Discord_Stream_Notify_Bot.Utility.GetDbStreamCount(), true); 54 | embedBuilder.AddField("上線時間", $"{Bot.StopWatch.Elapsed:d\\天\\ hh\\:mm\\:ss}", false); 55 | 56 | await RespondAsync(embed: embedBuilder.Build()); 57 | } 58 | 59 | [SlashCommand("send-message-to-bot-owner", "聯繫 Bot 擁有者")] 60 | [DefaultMemberPermissions(GuildPermission.Administrator)] 61 | public async Task SendMessageToBotOwner() 62 | { 63 | var modalBuilder = new ModalBuilder().WithTitle("聯繫 Bot 擁有者") 64 | .WithCustomId("send-message-to-bot-owner") 65 | .AddTextInput("訊息", "message", TextInputStyle.Paragraph, "請輸入你要發送的訊息", 10, null, true) 66 | .AddTextInput("聯繫方式", "contact-method", TextInputStyle.Short, "請輸入可與你聯繫的方式及相關資訊 (推特、Discord、Facebook等)", 3, null, true); 67 | 68 | await RespondWithModalAsync(modalBuilder.Build()); 69 | } 70 | 71 | [SlashCommand("set-global-notice-channel", "設定要接收 Bot 擁有者發送的訊息頻道")] 72 | [DefaultMemberPermissions(GuildPermission.Administrator)] 73 | public async Task SetGlobalNoticeChannel([Summary("接收通知的頻道"), ChannelTypes(ChannelType.Text, ChannelType.News)] IChannel channel) 74 | { 75 | try 76 | { 77 | var textChannel = channel as IGuildChannel; 78 | var permissions = Context.Guild.GetUser(_client.CurrentUser.Id).GetPermissions(textChannel); 79 | if (!permissions.ViewChannel || !permissions.SendMessages) 80 | { 81 | await Context.Interaction.SendErrorAsync($"我在 `{textChannel}` 沒有 `讀取&編輯頻道` 的權限,請給予權限後再次執行本指令"); 82 | return; 83 | } 84 | 85 | if (!permissions.EmbedLinks) 86 | { 87 | await Context.Interaction.SendErrorAsync($"我在 `{textChannel}` 沒有 `嵌入連結` 的權限,請給予權限後再次執行本指令"); 88 | return; 89 | } 90 | 91 | using var db = _dbService.GetDbContext(); 92 | var guildConfig = db.GuildConfig.FirstOrDefault((x) => x.GuildId == Context.Guild.Id) ?? new DataBase.Table.GuildConfig(); 93 | guildConfig.NoticeChannelId = channel.Id; 94 | db.SaveChanges(); 95 | 96 | await Context.Interaction.SendConfirmAsync($"已設定全球通知頻道為: {channel}", ephemeral: true); 97 | } 98 | catch (Exception ex) 99 | { 100 | Log.Error(ex.Demystify(), "Set Notice Channel Error"); 101 | await Context.Interaction.SendErrorAsync($"設定全球通知失敗,請向 Bot 擁有者詢問"); 102 | } 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Log.cs: -------------------------------------------------------------------------------- 1 | public static class Log 2 | { 3 | enum LogType { Verb, Stream, Info, Warn, Error } 4 | static string logPath = DateTime.Now.ToString("yyyy-MM-dd HH-mm-ss") + ".log"; 5 | static string errorLogPath = DateTime.Now.ToString("yyyy-MM-dd HH-mm-ss") + "_err.log"; 6 | static string streamLogPath = DateTime.Now.ToString("yyyy-MM-dd HH-mm-ss") + "_stream.log"; 7 | private static readonly object writeLockObj = new(); 8 | private static readonly object logLockObj = new(); 9 | 10 | private static void WriteLogToFile(LogType type, string text) 11 | { 12 | if (Debugger.IsAttached) 13 | return; 14 | 15 | lock (writeLockObj) 16 | { 17 | text = $"[{DateTime.Now:yyyy/MM/dd HH:mm:ss}] [{type.ToString().ToUpper()}] | {text}\r\n"; 18 | 19 | switch (type) 20 | { 21 | case LogType.Error: 22 | File.AppendAllText(errorLogPath, text); 23 | break; 24 | case LogType.Stream: 25 | File.AppendAllText(streamLogPath, text); 26 | break; 27 | } 28 | 29 | File.AppendAllText(logPath, text); 30 | } 31 | } 32 | 33 | public static void New(string text, bool newLine = true) 34 | { 35 | lock (logLockObj) 36 | { 37 | FormatColorWrite(text, ConsoleColor.Green, newLine); 38 | WriteLogToFile(LogType.Stream, text); 39 | } 40 | } 41 | 42 | public static void Debug(string text, bool newLine = true) 43 | { 44 | if (!Debugger.IsAttached) 45 | return; 46 | 47 | lock (logLockObj) 48 | { 49 | FormatColorWrite(text, ConsoleColor.Cyan, newLine); 50 | } 51 | } 52 | 53 | public static void Info(string text, bool newLine = true) 54 | { 55 | lock (logLockObj) 56 | { 57 | FormatColorWrite(text, ConsoleColor.DarkYellow, newLine); 58 | WriteLogToFile(LogType.Info, text); 59 | } 60 | } 61 | 62 | public static void Warn(string text, bool newLine = true) 63 | { 64 | lock (logLockObj) 65 | { 66 | FormatColorWrite(text, ConsoleColor.DarkMagenta, newLine); 67 | WriteLogToFile(LogType.Warn, text); 68 | } 69 | } 70 | 71 | public static void Error(string text, bool newLine = true, bool writeLog = true) 72 | { 73 | lock (logLockObj) 74 | { 75 | FormatColorWrite(text, ConsoleColor.DarkRed, newLine); 76 | if (writeLog) WriteLogToFile(LogType.Error, text); 77 | } 78 | } 79 | 80 | public static void Error(Exception ex, string text, bool newLine = true, bool writeLog = true) 81 | { 82 | lock (logLockObj) 83 | { 84 | FormatColorWrite(text, ConsoleColor.DarkRed, newLine, true); 85 | FormatColorWrite(ex.Demystify().ToString(), ConsoleColor.DarkRed, true, true); 86 | 87 | if (writeLog) 88 | { 89 | WriteLogToFile(LogType.Error, $"{text}"); 90 | WriteLogToFile(LogType.Error, $"{ex}"); 91 | } 92 | } 93 | } 94 | 95 | public static void FormatColorWrite(string text, ConsoleColor consoleColor = ConsoleColor.Gray, bool newLine = true, bool isError = false) 96 | { 97 | text = $"[{DateTime.Now:yyyy/MM/dd HH:mm:ss}] {text}"; 98 | Console.ForegroundColor = consoleColor; 99 | 100 | if (isError) 101 | { 102 | if (newLine) 103 | { 104 | Console.Error.WriteLine(text); 105 | } 106 | else 107 | { 108 | Console.Error.Write(text); 109 | } 110 | } 111 | else 112 | { 113 | if (newLine) 114 | { 115 | Console.WriteLine(text); 116 | } 117 | else 118 | { 119 | Console.Write(text); 120 | } 121 | } 122 | 123 | Console.ForegroundColor = ConsoleColor.Gray; 124 | } 125 | 126 | public static Task LogMsg(LogMessage message) 127 | { 128 | ConsoleColor consoleColor = ConsoleColor.DarkCyan; 129 | 130 | switch (message.Severity) 131 | { 132 | case LogSeverity.Error: 133 | consoleColor = ConsoleColor.DarkRed; 134 | break; 135 | case LogSeverity.Warning: 136 | consoleColor = ConsoleColor.DarkMagenta; 137 | break; 138 | case LogSeverity.Debug: 139 | consoleColor = ConsoleColor.Green; 140 | break; 141 | } 142 | 143 | #if DEBUG || DEBUG_DONTREGISTERCOMMAND 144 | if (!string.IsNullOrEmpty(message.Message)) FormatColorWrite(message.Message, consoleColor); 145 | #else 146 | WriteLogToFile(LogType.Verb, message.Message); 147 | #endif 148 | 149 | if (message.Exception != null && 150 | message.Message != null && 151 | !message.Message.Contains("TYPING_START") && 152 | (message.Exception is not GatewayReconnectException && 153 | message.Exception is not TaskCanceledException && 154 | message.Exception is not JsonSerializationException && 155 | message.Exception is not NullReferenceException)) 156 | { 157 | consoleColor = ConsoleColor.DarkRed; 158 | #if RELEASE 159 | FormatColorWrite(message.Message, consoleColor); 160 | #endif 161 | FormatColorWrite(message.Exception.GetType().FullName, consoleColor); 162 | FormatColorWrite(message.Exception.Message, consoleColor); 163 | FormatColorWrite(message.Exception.StackTrace, consoleColor); 164 | } 165 | 166 | return Task.CompletedTask; 167 | } 168 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/konnokai/Discord-Stream-Notify-Bot/90f25fca5ca9b642f630fe368435184dacb96f12/Discord Stream Notify Bot/Logo.png -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Logo_128.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/konnokai/Discord-Stream-Notify-Bot/90f25fca5ca9b642f630fe368435184dacb96f12/Discord Stream Notify Bot/Logo_128.ico -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Logo_64.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/konnokai/Discord-Stream-Notify-Bot/90f25fca5ca9b642f630fe368435184dacb96f12/Discord Stream Notify Bot/Logo_64.ico -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Migrations/20250603065853_ModifyTwitCastingTable.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace Discord_Stream_Notify_Bot.Migrations 6 | { 7 | /// 8 | public partial class ModifyTwitCastingTable : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.RenameColumn( 14 | name: "channel_id", 15 | table: "notice_twitcasting_stream_channels", 16 | newName: "screen_id"); 17 | 18 | migrationBuilder.AddColumn( 19 | name: "screen_id", 20 | table: "twitcasting_spider", 21 | type: "longtext", 22 | nullable: true) 23 | .Annotation("MySql:CharSet", "utf8mb4"); 24 | } 25 | 26 | /// 27 | protected override void Down(MigrationBuilder migrationBuilder) 28 | { 29 | migrationBuilder.DropColumn( 30 | name: "screen_id", 31 | table: "twitcasting_spider"); 32 | 33 | migrationBuilder.RenameColumn( 34 | name: "screen_id", 35 | table: "notice_twitcasting_stream_channels", 36 | newName: "channel_id"); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace Discord_Stream_Notify_Bot 4 | { 5 | public class Program 6 | { 7 | public static string Version => GetLinkerTime(Assembly.GetEntryAssembly()); 8 | 9 | static void Main(string[] args) 10 | { 11 | Log.Info(Version + " 初始化中"); 12 | Console.OutputEncoding = System.Text.Encoding.UTF8; 13 | Console.CancelKeyPress += Console_CancelKeyPress; 14 | 15 | // https://stackoverflow.com/q/5710148/15800522 16 | AppDomain.CurrentDomain.UnhandledException += (sender, e) => 17 | { 18 | Exception ex = (Exception)e.ExceptionObject; 19 | 20 | try 21 | { 22 | if (!Debugger.IsAttached) 23 | { 24 | StreamWriter sw = new StreamWriter($"{DateTime.Now:yyyy-MM-dd hh-mm-ss}_crash.log"); 25 | sw.WriteLine("### Bot Crash ###"); 26 | sw.WriteLine(ex.Demystify().ToString()); 27 | sw.Close(); 28 | } 29 | 30 | Log.Error(ex.Demystify(), "UnhandledException", true, false); 31 | } 32 | finally 33 | { 34 | Environment.Exit(1); 35 | } 36 | }; 37 | 38 | if (!Directory.Exists(Path.GetDirectoryName(Utility.GetDataFilePath("")))) 39 | Directory.CreateDirectory(Path.GetDirectoryName(Utility.GetDataFilePath(""))); 40 | 41 | // Todo: 改 Shard 架構後需要同步清單給其他 Shard 42 | if (File.Exists(Utility.GetDataFilePath("OfficialList.json"))) 43 | { 44 | try 45 | { 46 | Utility.OfficialGuildList = JsonConvert.DeserializeObject>(File.ReadAllText(Utility.GetDataFilePath("OfficialList.json"))); 47 | } 48 | catch (Exception ex) 49 | { 50 | Log.Error(ex.Demystify(), "ReadOfficialListFile Error"); 51 | return; 52 | } 53 | } 54 | 55 | int shardId = 0; 56 | int totalShards = 1; 57 | if (args.Length > 0 && args[0] != "run") 58 | { 59 | if (!int.TryParse(args[0], out shardId)) 60 | { 61 | Console.Error.WriteLine("Invalid first argument (shard id): {0}", args[0]); 62 | return; 63 | } 64 | 65 | if (args.Length > 1) 66 | { 67 | if (!int.TryParse(args[1], out var shardCount)) 68 | { 69 | Console.Error.WriteLine("Invalid second argument (total shards): {0}", args[1]); 70 | return; 71 | } 72 | 73 | totalShards = shardCount; 74 | } 75 | } 76 | 77 | var bot = new Bot(shardId, totalShards); 78 | bot.StartAndBlockAsync().GetAwaiter().GetResult(); 79 | } 80 | 81 | private static void Console_CancelKeyPress(object sender, ConsoleCancelEventArgs e) 82 | { 83 | Bot.IsDisconnect = true; 84 | e.Cancel = true; 85 | } 86 | 87 | public static string GetLinkerTime(Assembly assembly) 88 | { 89 | const string BuildVersionMetadataPrefix = "+build"; 90 | 91 | var attribute = assembly.GetCustomAttribute(); 92 | if (attribute?.InformationalVersion != null) 93 | { 94 | var value = attribute.InformationalVersion; 95 | var index = value.IndexOf(BuildVersionMetadataPrefix); 96 | if (index > 0) 97 | { 98 | value = value[(index + BuildVersionMetadataPrefix.Length)..]; 99 | return value; 100 | } 101 | } 102 | return default; 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/RedisConnection.cs: -------------------------------------------------------------------------------- 1 | public sealed class RedisConnection 2 | { 3 | private static Lazy lazy = new Lazy(() => 4 | { 5 | if (String.IsNullOrEmpty(_settingOption)) throw new InvalidOperationException("Please call Init() first."); 6 | return new RedisConnection(); 7 | }); 8 | 9 | private static string _settingOption; 10 | 11 | public readonly ConnectionMultiplexer ConnectionMultiplexer; 12 | 13 | public static RedisConnection Instance 14 | { 15 | get 16 | { 17 | return lazy.Value; 18 | } 19 | } 20 | 21 | private RedisConnection() 22 | { 23 | ConnectionMultiplexer = ConnectionMultiplexer.Connect(_settingOption); 24 | } 25 | 26 | public static void Init(string settingOption) 27 | { 28 | _settingOption = settingOption; 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/RedisDataStore.cs: -------------------------------------------------------------------------------- 1 | using Discord_Stream_Notify_Bot.Auth; 2 | using Google.Apis.Util.Store; 3 | 4 | namespace Discord_Stream_Notify_Bot 5 | { 6 | public class RedisDataStore : IDataStore 7 | { 8 | private readonly IDatabase _database; 9 | private readonly string _key = Utility.RedisKey; 10 | 11 | public RedisDataStore(ConnectionMultiplexer connectionMultiplexer) 12 | { 13 | _database = connectionMultiplexer.GetDatabase(1); 14 | } 15 | 16 | public Task ClearAsync() 17 | { 18 | throw new NotImplementedException(); 19 | } 20 | 21 | public async Task DeleteAsync(string key) 22 | { 23 | await _database.KeyDeleteAsync(GenerateStoredKey(key, typeof(T))); 24 | } 25 | 26 | public async Task GetAsync(string key) 27 | { 28 | if (!await _database.KeyExistsAsync(GenerateStoredKey(key, typeof(T)))) 29 | return default(T); 30 | 31 | var str = (await _database.StringGetAsync(GenerateStoredKey(key, typeof(T)))).ToString(); 32 | 33 | try 34 | { 35 | return TokenManager.GetTokenResponseValue(str, _key); 36 | } 37 | catch (Exception ex) 38 | { 39 | Log.Warn($"RedisDataStore-GetAsync ({key}): 解密失敗,也許還沒加密? {ex}"); 40 | 41 | try 42 | { 43 | return JsonConvert.DeserializeObject(str); 44 | } 45 | catch (Exception ex2) 46 | { 47 | Log.Error($"RedisDataStore-GetAsync ({key}): JsonDes失敗 {ex2}"); 48 | return default(T); 49 | } 50 | } 51 | } 52 | 53 | public async Task StoreAsync(string key, T value) 54 | { 55 | var encValue = TokenManager.CreateToken(value, _key); 56 | await _database.StringSetAsync(GenerateStoredKey(key, typeof(T)), encValue); 57 | } 58 | 59 | public static string GenerateStoredKey(string key, Type t) 60 | { 61 | return string.Format("{0}:{1}", t.FullName, key); 62 | } 63 | 64 | public async Task IsExistUserTokenAsync(string key) 65 | { 66 | return await _database.KeyExistsAsync(GenerateStoredKey(key, typeof(T))); 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/SharedService/EmojiService.cs: -------------------------------------------------------------------------------- 1 | using Discord_Stream_Notify_Bot.Interaction; 2 | 3 | namespace Discord_Stream_Notify_Bot.SharedService 4 | { 5 | public class EmojiService : IInteractionService 6 | { 7 | public Emote YouTubeEmote { get; private set; } 8 | public Emote PayPalEmote { get; private set; } 9 | public Emote ECPayEmote { get; private set; } 10 | 11 | private readonly DiscordSocketClient _client; 12 | 13 | public EmojiService(DiscordSocketClient client, BotConfig botConfig) 14 | { 15 | _client = client; 16 | 17 | try 18 | { 19 | YouTubeEmote = _client.GetApplicationEmoteAsync(botConfig.YouTubeEmoteId).GetAwaiter().GetResult(); 20 | } 21 | catch (Exception ex) 22 | { 23 | Log.Error($"無法取得 YouTube Emote: {ex}"); 24 | YouTubeEmote = null; 25 | } 26 | 27 | try 28 | { 29 | PayPalEmote = _client.GetApplicationEmoteAsync(botConfig.PayPalEmoteId).GetAwaiter().GetResult(); 30 | } 31 | catch (Exception ex) 32 | { 33 | Log.Error($"無法取得 PayPal Emote: {ex}"); 34 | PayPalEmote = null; 35 | } 36 | 37 | try 38 | { 39 | ECPayEmote = _client.GetApplicationEmoteAsync(botConfig.ECPayEmoteId).GetAwaiter().GetResult(); 40 | } 41 | catch (Exception ex) 42 | { 43 | Log.Error($"無法取得 ECPay Emote: {ex}"); 44 | ECPayEmote = null; 45 | } 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/SharedService/Twitcasting/TwitCastingWebHookJson.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.SharedService.Twitcasting 2 | { 3 | public class TwitCastingWebHookJson 4 | { 5 | [JsonProperty("signature")] 6 | public string Signature { get; set; } 7 | 8 | [JsonProperty("movie")] 9 | public Movie Movie { get; set; } 10 | 11 | [JsonProperty("broadcaster")] 12 | public Broadcaster Broadcaster { get; set; } 13 | } 14 | 15 | public class Broadcaster 16 | { 17 | [JsonProperty("id")] 18 | public string Id { get; set; } 19 | 20 | [JsonProperty("screen_id")] 21 | public string ScreenId { get; set; } 22 | 23 | [JsonProperty("name")] 24 | public string Name { get; set; } 25 | 26 | [JsonProperty("image")] 27 | public string Image { get; set; } 28 | 29 | [JsonProperty("profile")] 30 | public string Profile { get; set; } 31 | 32 | [JsonProperty("level")] 33 | public int Level { get; set; } 34 | 35 | [JsonProperty("last_movie_id")] 36 | public string LastMovieId { get; set; } 37 | 38 | [JsonProperty("is_live")] 39 | public bool IsLive { get; set; } 40 | 41 | [JsonProperty("supporter_count")] 42 | public int SupporterCount { get; set; } 43 | 44 | [JsonProperty("supporting_count")] 45 | public int SupportingCount { get; set; } 46 | 47 | [JsonProperty("created")] 48 | public int Created { get; set; } 49 | } 50 | 51 | public class Movie 52 | { 53 | [JsonProperty("id")] 54 | public string Id { get; set; } 55 | 56 | [JsonProperty("user_id")] 57 | public string UserId { get; set; } 58 | 59 | [JsonProperty("title")] 60 | public string Title { get; set; } 61 | 62 | [JsonProperty("subtitle")] 63 | public string Subtitle { get; set; } 64 | 65 | [JsonProperty("last_owner_comment")] 66 | public string LastOwnerComment { get; set; } 67 | 68 | [JsonProperty("category")] 69 | public string Category { get; set; } 70 | 71 | [JsonProperty("link")] 72 | public string Link { get; set; } 73 | 74 | [JsonProperty("is_live")] 75 | public bool IsLive { get; set; } 76 | 77 | [JsonProperty("is_recorded")] 78 | public bool IsRecorded { get; set; } 79 | 80 | [JsonProperty("comment_count")] 81 | public int CommentCount { get; set; } 82 | 83 | [JsonProperty("large_thumbnail")] 84 | public string LargeThumbnail { get; set; } 85 | 86 | [JsonProperty("small_thumbnail")] 87 | public string SmallThumbnail { get; set; } 88 | 89 | [JsonProperty("country")] 90 | public string Country { get; set; } 91 | 92 | [JsonProperty("duration")] 93 | public int Duration { get; set; } 94 | 95 | [JsonProperty("created")] 96 | public int Created { get; set; } 97 | 98 | [JsonProperty("is_collabo")] 99 | public bool IsCollabo { get; set; } 100 | 101 | [JsonProperty("is_protected")] 102 | public bool IsProtected { get; set; } 103 | 104 | [JsonProperty("max_view_count")] 105 | public int MaxViewCount { get; set; } 106 | 107 | [JsonProperty("current_view_count")] 108 | public int CurrentViewCount { get; set; } 109 | 110 | [JsonProperty("total_view_count")] 111 | public int TotalViewCount { get; set; } 112 | 113 | [JsonProperty("hls_url")] 114 | public string HlsUrl { get; set; } 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/SharedService/Twitch/Debounce/DebounceChannelUpdateMessage.cs: -------------------------------------------------------------------------------- 1 | using Discord_Stream_Notify_Bot.Interaction; 2 | using Dorssel.Utilities; 3 | using System.Collections.Concurrent; 4 | using static Discord_Stream_Notify_Bot.SharedService.Twitch.TwitchService; 5 | 6 | namespace Discord_Stream_Notify_Bot.SharedService.Twitch.Debounce 7 | { 8 | // https://blog.darkthread.net/blog/dotnet-debounce/ 9 | // https://github.com/dorssel/dotnet-debounce 10 | internal class DebounceChannelUpdateMessage 11 | { 12 | private readonly Debouncer _debouncer; 13 | private readonly TwitchService _twitchService; 14 | private readonly string _twitchUserName, _twitchUserLogin, _twitchUserId; 15 | private readonly ConcurrentQueue messageQueue = new(); 16 | 17 | public DebounceChannelUpdateMessage(TwitchService twitchService, string twitchUserName, string twitchUserLogin, string twitchUserId) 18 | { 19 | _twitchService = twitchService; 20 | _twitchUserName = twitchUserName; 21 | _twitchUserLogin = twitchUserLogin; 22 | _twitchUserId = twitchUserId; 23 | 24 | _debouncer = new() 25 | { 26 | DebounceWindow = TimeSpan.FromMinutes(1), 27 | DebounceTimeout = TimeSpan.FromMinutes(3), 28 | }; 29 | _debouncer.Debounced += _debouncer_Debounced; 30 | } 31 | 32 | private void _debouncer_Debounced(object sender, DebouncedEventArgs e) 33 | { 34 | try 35 | { 36 | Log.Info($"{_twitchUserLogin} 發送頻道更新通知 (Debouncer 觸發數量: {e.Count})"); 37 | 38 | var description = string.Join("\n\n", messageQueue); 39 | 40 | var embedBuilder = new EmbedBuilder() 41 | .WithOkColor() 42 | .WithTitle($"{_twitchUserName} 直播資料更新") 43 | .WithUrl($"https://twitch.tv/{_twitchUserLogin}") 44 | .WithDescription(description); 45 | 46 | using var db = Bot.DbService.GetDbContext(); 47 | var twitchSpider = db.TwitchSpider.AsNoTracking().FirstOrDefault((x) => x.UserId == _twitchUserId); 48 | if (twitchSpider != null) 49 | embedBuilder.WithThumbnailUrl(twitchSpider.ProfileImageUrl); 50 | 51 | Task.Run(async () => { await _twitchService.SendStreamMessageAsync(_twitchUserId, embedBuilder.Build(), NoticeType.ChangeStreamData); }); 52 | } 53 | catch (Exception ex) 54 | { 55 | Log.Error(ex.Demystify(), $"{_twitchUserLogin} 訊息去抖動失敗"); 56 | } 57 | finally 58 | { 59 | messageQueue.Clear(); 60 | _debouncer.Reset(); 61 | } 62 | } 63 | 64 | public void AddMessage(string message) 65 | { 66 | Log.Debug($"DebounceChannelUpdateMessage ({_twitchUserLogin}): {message}"); 67 | 68 | messageQueue.Enqueue(message); 69 | _debouncer.Trigger(); 70 | } 71 | 72 | bool isDisposed; 73 | public void Dispose() 74 | { 75 | if (!isDisposed) 76 | { 77 | _debouncer.Debounced -= _debouncer_Debounced; 78 | _debouncer.Dispose(); 79 | isDisposed = true; 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/SharedService/Youtube/ChangeGuildBanner.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.SharedService.Youtube 2 | { 3 | public partial class YoutubeStreamService 4 | { 5 | private async Task ChangeGuildBannerAsync(string channelId, string videoId) 6 | { 7 | #if DEBUG || DEBUG_DONTREGISTERCOMMAND 8 | return; 9 | #endif 10 | List list; 11 | 12 | using (var db = _dbService.GetDbContext()) 13 | { 14 | list = _dbService.GetDbContext().BannerChange.AsNoTracking() 15 | .Where(x => x.ChannelId == channelId) 16 | .ToList(); 17 | } 18 | 19 | if (list.Count == 0) return; 20 | 21 | foreach (var item in list) 22 | { 23 | try 24 | { 25 | var guild = _client.GetGuild(item.GuildId); 26 | if (guild == null) 27 | { 28 | Log.Warn($"Guild not found: {item.GuildId} / {channelId} / {videoId}"); 29 | using (var db = _dbService.GetDbContext()) 30 | { 31 | db.BannerChange.Remove(item); 32 | await db.SaveChangesAsync(); 33 | } 34 | continue; 35 | } 36 | 37 | if (guild.PremiumTier < PremiumTier.Tier2) continue; 38 | 39 | if (videoId != item.LastChangeStreamId) 40 | { 41 | MemoryStream memStream; 42 | try 43 | { 44 | memStream = new MemoryStream(await _httpClientFactory.CreateClient("").GetByteArrayAsync($"https://i.ytimg.com/vi/{videoId}/maxresdefault.jpg")); 45 | if (memStream.Length < 2048) memStream = null; 46 | } 47 | catch (Exception ex) 48 | { 49 | Log.Error($"DownloadGuildBanner - {item.GuildId}\n" + 50 | $"{channelId} / {videoId}\n" + 51 | $"{ex.Message}\n" + 52 | $"{ex.StackTrace}"); 53 | continue; 54 | } 55 | 56 | try 57 | { 58 | if (memStream != null) 59 | { 60 | Image image = new Image(memStream); 61 | await guild.ModifyAsync((func) => func.Banner = image); 62 | } 63 | 64 | item.LastChangeStreamId = videoId; 65 | 66 | using (var db = _dbService.GetDbContext()) 67 | { 68 | db.BannerChange.Update(item); 69 | await db.SaveChangesAsync(); 70 | } 71 | 72 | Log.Info("ChangeGuildBanner" + (memStream == null ? "(Without Change)" : "") + $": {item.GuildId} / {videoId}"); 73 | } 74 | catch (Exception ex) 75 | { 76 | Log.Error(ex.Demystify(), $"ChangeGuildBanner - {item.GuildId}: {channelId} / {videoId}"); 77 | continue; 78 | } 79 | } 80 | } 81 | catch (Exception ex) 82 | { 83 | Log.Error(ex.Demystify(), $"ChangeGuildBanner - {item.GuildId}"); 84 | continue; 85 | } 86 | } 87 | } 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/SharedService/Youtube/Json/NijisanjiLiverJson.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.SharedService.Youtube.Json 2 | { 3 | public class NijisanjiLiverJson 4 | { 5 | public string slug { get; set; } 6 | public bool? hidden { get; set; } 7 | public string name { get; set; } 8 | public string enName { get; set; } 9 | public Images images { get; set; } 10 | public SocialLinks socialLinks { get; set; } 11 | public SiteColor siteColor { get; set; } 12 | public string id { get; set; } 13 | public int? subscriberCount { get; set; } 14 | } 15 | 16 | public class Fullbody 17 | { 18 | public string url { get; set; } 19 | public int? height { get; set; } 20 | public int? width { get; set; } 21 | } 22 | 23 | public class Halfbody 24 | { 25 | public string url { get; set; } 26 | public int? height { get; set; } 27 | public int? width { get; set; } 28 | } 29 | 30 | public class Head 31 | { 32 | public string url { get; set; } 33 | public int? height { get; set; } 34 | public int? width { get; set; } 35 | } 36 | 37 | public class Images 38 | { 39 | public string fieldId { get; set; } 40 | public Fullbody fullbody { get; set; } 41 | public Halfbody halfbody { get; set; } 42 | public Head head { get; set; } 43 | public List variation { get; set; } 44 | } 45 | 46 | public class SiteColor 47 | { 48 | public string id { get; set; } 49 | public DateTime? createdAt { get; set; } 50 | public DateTime? updatedAt { get; set; } 51 | public DateTime? publishedAt { get; set; } 52 | public DateTime? revisedAt { get; set; } 53 | public string name { get; set; } 54 | public string color1 { get; set; } 55 | public string color2 { get; set; } 56 | } 57 | 58 | public class SocialLinks 59 | { 60 | public string fieldId { get; set; } 61 | public string twitter { get; set; } 62 | public string youtube { get; set; } 63 | public string twitch { get; set; } 64 | public string reddit { get; set; } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/SharedService/Youtube/Json/NijisanjiStreamJson.cs: -------------------------------------------------------------------------------- 1 | namespace Discord_Stream_Notify_Bot.SharedService.Youtube.Json 2 | { 3 | public class NijisanjiStreamJson 4 | { 5 | [JsonProperty("data")] 6 | public List Data { get; set; } 7 | 8 | [JsonProperty("included")] 9 | public List Included { get; set; } 10 | } 11 | 12 | public class Attributes 13 | { 14 | [JsonProperty("title")] 15 | public string Title { get; set; } 16 | 17 | [JsonProperty("description")] 18 | public string Description { get; set; } 19 | 20 | [JsonProperty("url")] 21 | public string Url { get; set; } 22 | 23 | [JsonProperty("thumbnail_url")] 24 | public string ThumbnailUrl { get; set; } 25 | 26 | [JsonProperty("start_at")] 27 | public DateTime? StartAt { get; set; } 28 | 29 | [JsonProperty("end_at")] 30 | public DateTime? EndAt { get; set; } 31 | 32 | [JsonProperty("status")] 33 | public string Status { get; set; } 34 | 35 | [JsonProperty("external_id")] 36 | public string ExternalId { get; set; } 37 | 38 | [JsonProperty("name")] 39 | public string Name { get; set; } 40 | 41 | [JsonProperty("main")] 42 | public bool? Main { get; set; } 43 | } 44 | 45 | public class Data 46 | { 47 | [JsonProperty("id")] 48 | public string Id { get; set; } 49 | 50 | [JsonProperty("type")] 51 | public string Type { get; set; } 52 | 53 | [JsonProperty("attributes")] 54 | public Attributes Attributes { get; set; } 55 | 56 | [JsonProperty("relationships")] 57 | public Relationships Relationships { get; set; } 58 | } 59 | 60 | public class Liver 61 | { 62 | [JsonProperty("data")] 63 | public Data Data { get; set; } 64 | } 65 | 66 | public class Relationships 67 | { 68 | [JsonProperty("youtube_channel")] 69 | public YoutubeChannel YoutubeChannel { get; set; } 70 | 71 | [JsonProperty("youtube_events_livers")] 72 | public YoutubeEventsLivers YoutubeEventsLivers { get; set; } 73 | 74 | [JsonProperty("youtube_channels")] 75 | public YoutubeChannels YoutubeChannels { get; set; } 76 | 77 | [JsonProperty("liver")] 78 | public Liver Liver { get; set; } 79 | 80 | [JsonProperty("youtube_events")] 81 | public YoutubeEvents YoutubeEvents { get; set; } 82 | } 83 | 84 | public class YoutubeChannel 85 | { 86 | [JsonProperty("data")] 87 | public Data Data { get; set; } 88 | } 89 | 90 | public class YoutubeChannels 91 | { 92 | } 93 | 94 | public class YoutubeEvents 95 | { 96 | } 97 | 98 | public class YoutubeEventsLivers 99 | { 100 | [JsonProperty("data")] 101 | public List Data { get; set; } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Discord Stream Notify Bot/SharedService/YoutubeMember/CheckMemberShipOnlyVideoId.cs: -------------------------------------------------------------------------------- 1 | using Discord_Stream_Notify_Bot.DataBase.Table; 2 | using Discord_Stream_Notify_Bot.Interaction; 3 | 4 | namespace Discord_Stream_Notify_Bot.SharedService.YoutubeMember 5 | { 6 | public partial class YoutubeMemberService 7 | { 8 | //https://github.com/member-gentei/member-gentei/blob/90f62385f554eb4c02ed8732e15061b9dd1dd6d0/gentei/apis/youtube.go#L100 9 | private async void CheckMemberShipOnlyVideoId(object stats) 10 | { 11 | List needCheckList = new(); 12 | using (var db = _dbService.GetDbContext()) 13 | { 14 | needCheckList = db.GuildYoutubeMemberConfig 15 | .AsNoTracking() 16 | .Where((x) => !string.IsNullOrEmpty(x.MemberCheckChannelId) && x.MemberCheckChannelId.Length == 24 && (x.MemberCheckVideoId == "-" || string.IsNullOrEmpty(x.MemberCheckChannelTitle))) 17 | .Distinct((x) => x.MemberCheckChannelId) 18 | .ToList(); 19 | } 20 | 21 | foreach (var item in needCheckList) 22 | { 23 | using var db = _dbService.GetDbContext(); 24 | 25 | try 26 | { 27 | var s = _streamService.YouTubeService.PlaylistItems.List("snippet"); 28 | s.PlaylistId = item.MemberCheckChannelId.Replace("UC", "UUMO"); 29 | var result = await s.ExecuteAsync().ConfigureAwait(false); 30 | var videoList = result.Items.ToList(); 31 | 32 | bool isCheck = false; 33 | do 34 | { 35 | if (videoList.Count == 0) 36 | { 37 | await Bot.ApplicatonOwner.SendMessageAsync($"{item.MemberCheckChannelId} 無任何可檢測的會限影片!"); 38 | await SendMsgToLogChannelAsync(item.MemberCheckChannelId, $"{item.MemberCheckChannelId} 無會限影片,請等待該頻道主有新的會限影片且可留言時再使用會限驗證功能\n" + 39 | $"你可以使用 `/youtube get-member-only-playlist` 來確認該頻道是否有可驗證的影片"); 40 | db.GuildYoutubeMemberConfig.Remove(item); 41 | break; 42 | } 43 | 44 | var videoSnippet = videoList[new Random().Next(0, videoList.Count)]; 45 | var videoId = videoSnippet.Snippet.ResourceId.VideoId; 46 | var ct = _streamService.YouTubeService.CommentThreads.List("snippet"); 47 | ct.VideoId = videoId; 48 | 49 | try 50 | { 51 | _ = await ct.ExecuteAsync().ConfigureAwait(false); 52 | } 53 | catch (Exception ex) 54 | { 55 | if (ex.Message.ToLower().Contains("disabled comments")) 56 | { 57 | videoList.Remove(videoSnippet); 58 | } 59 | else if (ex.Message.ToLower().Contains("403") || ex.Message.ToLower().Contains("the request might not be properly authorized")) 60 | { 61 | Log.Info($"新會限影片 - ({item.MemberCheckChannelId}): {videoId}"); 62 | await SendMsgToLogChannelAsync(item.MemberCheckChannelId, $"新會限檢測影片 - ({item.MemberCheckChannelId}): {videoId}", false, false); 63 | 64 | foreach (var item2 in db.GuildYoutubeMemberConfig.Where((x) => x.MemberCheckChannelId == item.MemberCheckChannelId)) 65 | { 66 | item2.MemberCheckVideoId = videoId; 67 | db.GuildYoutubeMemberConfig.Update(item2); 68 | } 69 | 70 | isCheck = true; 71 | } 72 | else 73 | { 74 | Log.Error(ex.Demystify(), $"{item.MemberCheckChannelId} 新會限影片檢查錯誤"); 75 | 76 | foreach (var item2 in db.GuildYoutubeMemberConfig.Where((x) => x.MemberCheckChannelId == item.MemberCheckChannelId)) 77 | { 78 | item2.MemberCheckVideoId = ""; 79 | db.GuildYoutubeMemberConfig.Update(item2); 80 | } 81 | 82 | isCheck = true; 83 | } 84 | } 85 | } while (!isCheck); 86 | } 87 | catch (Exception ex) 88 | { 89 | if (ex.Message.ToLower().Contains("playlistid")) 90 | { 91 | Log.Warn($"CheckMemberShipOnlyVideoId: {item.GuildId} / {item.MemberCheckChannelId} 無會限影片可供檢測"); 92 | await SendMsgToLogChannelAsync(item.MemberCheckChannelId, $"{item.MemberCheckChannelId} 無會限影片,請等待該頻道主有新的會限影片且可留言時再使用會限驗證功能\n" + 93 | $"你可以使用 `/youtube get-member-only-playlist` 來確認該頻道是否有可驗證的影片"); 94 | db.GuildYoutubeMemberConfig.Remove(item); 95 | continue; 96 | } 97 | else Log.Warn($"CheckMemberShipOnlyVideoId: {item.GuildId} / {item.MemberCheckChannelId}\n{ex}"); 98 | } 99 | 100 | try 101 | { 102 | var c = _streamService.YouTubeService.Channels.List("snippet"); 103 | c.Id = item.MemberCheckChannelId; 104 | var channelResult = await c.ExecuteAsync(); 105 | var channel = channelResult.Items.First(); 106 | 107 | Log.Info($"會限頻道名稱已變更 - ({item.MemberCheckChannelId}): `" + (string.IsNullOrEmpty(item.MemberCheckChannelTitle) ? "無" : item.MemberCheckChannelTitle) + $"` -> `{channel.Snippet.Title}`"); 108 | await SendMsgToLogChannelAsync(item.MemberCheckChannelId, $"會限頻道名稱已變更: `" + (string.IsNullOrEmpty(item.MemberCheckChannelTitle) ? "無" : item.MemberCheckChannelTitle) + $"` -> `{channel.Snippet.Title}`", false, false); 109 | 110 | foreach (var item2 in db.GuildYoutubeMemberConfig.Where((x) => x.MemberCheckChannelId == item.MemberCheckChannelId)) 111 | { 112 | item2.MemberCheckChannelTitle = channel.Snippet.Title; 113 | db.GuildYoutubeMemberConfig.Update(item2); 114 | } 115 | } 116 | catch (Exception ex) 117 | { 118 | Log.Warn($"CheckMemberShipOnlyChannelName: {item.GuildId} / {item.MemberCheckChannelId}\n{ex}"); 119 | } 120 | 121 | await db.SaveChangesAsync(); 122 | } 123 | 124 | //Log.Info("檢查新會限影片完成"); 125 | } 126 | } 127 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/UptimeKumaClient.cs: -------------------------------------------------------------------------------- 1 | public static class UptimeKumaClient 2 | { 3 | static Timer timerUptimeKumaPush; 4 | static DiscordSocketClient discordSocketClient; 5 | static HttpClient httpClient; 6 | static string uptimeKumaPushUrl; 7 | static bool isInit = false; 8 | 9 | public static bool Init(string uptimeKumaPushUrl, DiscordSocketClient discordSocketClient = null) 10 | { 11 | if (isInit) 12 | return false; 13 | 14 | if (string.IsNullOrEmpty(uptimeKumaPushUrl)) 15 | { 16 | Log.Warn($"未設定 {nameof(uptimeKumaPushUrl)} 的網址,略過檢測"); 17 | return false; 18 | } 19 | 20 | httpClient = new HttpClient(); 21 | UptimeKumaClient.uptimeKumaPushUrl = uptimeKumaPushUrl.Split('?')[0]; 22 | UptimeKumaClient.discordSocketClient = discordSocketClient; 23 | 24 | try 25 | { 26 | timerUptimeKumaPush = new Timer(async (state) => { await UptimeKumaTimerHandler(state); }); 27 | timerUptimeKumaPush.Change(0, 30 * 1000); 28 | 29 | Log.Info("已註冊 Uptime Kuma 狀態檢測"); 30 | } 31 | catch (Exception ex) 32 | { 33 | Log.Error($"UptimeKumaClient: {ex}"); 34 | } 35 | 36 | isInit = true; 37 | return true; 38 | } 39 | 40 | private static async Task UptimeKumaTimerHandler(object state) 41 | { 42 | try 43 | { 44 | string latency = discordSocketClient.Latency.ToString() ?? ""; 45 | var result = await httpClient.GetStringAsync($"{uptimeKumaPushUrl}?status=up&msg=OK&ping={latency}"); 46 | if (result != "{\"ok\":true}") 47 | { 48 | Log.Error("Uptime Kuma 回傳錯誤"); 49 | Log.Error(result); 50 | } 51 | } 52 | catch (TaskCanceledException timeout) 53 | { 54 | Log.Warn($"UptimeKumaTimerHandler-Timeout: {timeout.Message}"); 55 | } 56 | catch (HttpRequestException requestEx) 57 | { 58 | if (requestEx.Message.Contains("500") || requestEx.Message.Contains("530") || requestEx.Message.Contains("occurred while sending the request")) 59 | return; 60 | 61 | Log.Error($"UptimeKumaTimerHandler-RequestError: {requestEx.Message}"); 62 | } 63 | catch (Exception ex) 64 | { 65 | Log.Error($"UptimeKumaTimerHandler: {ex}"); 66 | } 67 | } 68 | } -------------------------------------------------------------------------------- /Discord Stream Notify Bot/Utility.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.InteropServices; 2 | 3 | namespace Discord_Stream_Notify_Bot 4 | { 5 | public static class Utility 6 | { 7 | public const string ECPayUrl = "https://p.ecpay.com.tw/B8CCC"; 8 | public const string PaypalUrl = "https://paypal.me/jun112561"; 9 | 10 | //static Regex videoIdRegex = new Regex(@"youtube_(?'ChannelId'[\w\-]{24})_(?'Date'[\d]{8})_(?'Time'[\d]{6})_(?'VideoId'[\w\-]{11}).mp4.part"); 11 | public static string RedisKey { get; set; } = ""; 12 | public static HashSet OfficialGuildList { get; set; } = new HashSet(); 13 | 14 | public static List GetNowRecordStreamList() 15 | { 16 | try 17 | { 18 | return Bot.RedisDb.SetMembers("youtube.nowRecord").Select((x) => x.ToString()).ToList(); 19 | } 20 | catch (Exception ex) 21 | { 22 | Log.Error(ex.ToString()); 23 | return new List(); 24 | } 25 | } 26 | 27 | public static int GetDbStreamCount() 28 | { 29 | try 30 | { 31 | int total = 0; 32 | 33 | using var db = Bot.DbService.GetDbContext(); 34 | total += db.HoloVideos.AsNoTracking().Count(); 35 | total += db.NijisanjiVideos.AsNoTracking().Count(); 36 | total += db.OtherVideos.AsNoTracking().Count(); 37 | 38 | return total; 39 | } 40 | catch (Exception ex) 41 | { 42 | Log.Error(ex.Demystify(), "Utility-GetDbStreamCount"); 43 | return 0; 44 | } 45 | } 46 | 47 | public static bool OfficialGuildContains(ulong guildId) => 48 | OfficialGuildList.Contains(guildId); 49 | 50 | public static string GetDataFilePath(string fileName) 51 | => $"{AppDomain.CurrentDomain.BaseDirectory}Data{GetPlatformSlash()}{fileName}"; 52 | 53 | public static string GetPlatformSlash() 54 | => RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? "\\" : "/"; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 Konnokai 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 直播小幫手 [點我邀請到你的 Discord 內](https://discordapp.com/api/oauth2/authorize?client_id=758222559392432160&permissions=2416143425&scope=bot%20applications.commands) 2 | 3 | ![Discord-Stream-Notify-Bot](https://socialify.git.ci/konnokai/Discord-Stream-Notify-Bot/image?description=1&descriptionEditable=%E4%B8%80%E5%80%8B%E5%8F%AF%E4%BB%A5%E8%AE%93%E4%BD%A0%E5%9C%A8%20Discord%20%E4%B8%8A%E9%80%9A%E7%9F%A5%20Vtuber%20%E7%9B%B4%E6%92%AD%E7%9A%84%E5%B0%8F%E5%B9%AB%E6%89%8B&font=Inter&language=1&name=1&owner=1&pattern=Plus&stargazers=1&theme=Auto) 4 | 5 | [![Website dcbot.konnokai.me](https://img.shields.io/website-up-down-green-red/http/dcbot.konnokai.me/stream.svg)](http://dcbot.konnokai.me/stream) 6 | [![GitHub commits](https://badgen.net/github/commits/konnokai/Discord-Stream-Notify-Bot)](https://GitHub.com/konnokai/Discord-Stream-Notify-Bot/commit/) 7 | [![GitHub latest commit](https://badgen.net/github/last-commit/konnokai/Discord-Stream-Notify-Bot)](https://GitHub.com/konnokai/Discord-Stream-Notify-Bot/commit/) 8 | 9 | 自行運行所需環境與參數 10 | - 11 | - .NET Core 6.0 Runtime 或 SDK ([微軟網址](https://dotnet.microsoft.com/en-us/download/dotnet/6.0)) 12 | - Redis Server ([Windows 下載網址](https://github.com/MicrosoftArchive/redis),Linux 可直接透過 apt 或 yum 安裝) 13 | - Discord Bot Token ([Discord Dev網址](https://discord.com/developers/applications)) 14 | - Discord Channel WebHook,做紀錄用 15 | - Google Console API 金鑰並確保已於程式庫開啟 Youtube Data API v3 ([Google Console網址](https://console.cloud.google.com/apis/library/youtube.googleapis.com)) 16 | - 錄影功能需搭配隔壁 [Youtube Stream Record](https://github.com/konnokai/YoutubeStreamRecord) 使用 (如無搭配錄影的話則不會有關台通知,且不能即時的通知開台) \* 17 | - Twitter AuthToken & CSRFToken,這需要從已登入的 Twitter 帳號中,由名稱為 `auth_token` 和 `ct0` 的 Cookie 來獲得 (如不需要推特語音通知則不需要) \*\* 18 | - Discord & Google 的 OAuth Client ID 跟 Client Secret,用於 YouTube 會限驗證,需搭配 [網站後端](https://github.com/konnokai/Discord-Stream-Bot-Backend) 使用 \*\* 19 | - ApiServerDomain,搭配上面的網站後端做 YouTube 影片上傳接收 & Twitch 狀態更新使用,僅需填寫後端域名就好 (Ex: api.example.me) ([Google PubSubHubbub](https://pubsubhubbub.appspot.com)) ([Twitch Webhook Callback](https://dev.twitch.tv/docs/eventsub/handling-webhook-events/)) 20 | - Uptime Kuma Push 監測器的網址,如果不需要上線監測則可為空,需搭配 [Uptime Kuma](https://github.com/louislam/uptime-kuma) 使用 21 | - [ffmpeg](https://ffmpeg.org/download.html), [streamlink](https://streamlink.github.io/install.html),原則上不裝的話就只是不會錄影 (裝完記得確認 PATH 環境變數是否有設定正確的路徑) 22 | - Twitch App Client Id & Client Secret ([Twitch Develpers](https://dev.twitch.tv/console/apps)) \*\* 23 | - TwitCasting Client Id & Client Secret ([TwitCasting Develpers](https://twitcasting.tv/developer.php)) \*\* 24 | 25 | 備註 26 | - 27 | 請使用 Release 組態進行編譯,Debug 組態有忽略掉不少東西會導致功能出現異常等錯誤 28 | 29 | 如需要自行改程式碼也記得確認 Debug 組態下的 `#if` 是否會導致偵錯問題 30 | 31 | \* 未錄影的話則是固定在排定開台時間的前一分鐘通知,若有開啟錄影則會在錄影環境偵測到開始錄影時一併發送開台通知 32 | 33 | \*\* 未設定的話則僅該功能無法使用,在使用該功能的時會有錯誤提示 34 | 35 | 建置&測試環境 36 | - 37 | - Visual Studio 2022 38 | - .NET SDK 6.0 39 | - Windows 10 & 11 Pro 40 | - Debian 11 41 | - Redis 7.0.4 42 | 43 | 參考專案 44 | - 45 | - [NadekoBot](https://gitlab.com/Kwoth/nadekobot) 46 | - [LivestreamRecorderService](https://github.com/Recorder-moe/LivestreamRecorderService) 47 | - [Discord .NET](https://github.com/discord-net/Discord.Net) 48 | - [TwitchLib](https://github.com/TwitchLib/TwitchLib) 49 | - [twspace-crawler](https://github.com/HitomaruKonpaku/twspace-crawler) 50 | - 其餘參考附於程式碼內 51 | 52 | 授權 53 | - 54 | - 此專案採用 [MIT](https://github.com/konnokai/Discord-Stream-Notify-Bot/blob/master/LICENSE.txt) 授權 55 | --------------------------------------------------------------------------------