├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── Sisters.WudiLib.Tests ├── EndpointTests.cs ├── ListenerTests.cs ├── SectionTests.cs ├── SendingMessageTests.cs └── Sisters.WudiLib.Tests.csproj ├── Sisters.WudiLib.WebSocket.Test ├── Program.cs └── Sisters.WudiLib.WebSocket.Test.csproj ├── Sisters.WudiLib.WebSocket ├── CqHttpWebSocketApiClient.cs ├── CqHttpWebSocketEvent.cs ├── HttpApiClientExtentions.cs ├── IEventReceiver.cs ├── IRequestSender.cs ├── PositiveWebSocketManager.cs ├── Properties │ └── PublishProfiles │ │ └── FolderProfile.pubxml ├── Reverse │ ├── NegativeWebSocketEventListener.cs │ ├── NegativeWebSocketManager.cs │ ├── ReverseConnectionInfo.cs │ └── ReverseWebSocketServer.cs ├── Sisters.WudiLib.WebSocket.csproj ├── WebSocketEventExtensions.cs ├── WebSocketManager.cs └── WebSocketUtility.cs ├── Sisters.WudiLib.sln ├── Sisters.WudiLib ├── Builders │ ├── Annotations │ │ └── PostAttribute.cs │ ├── DispatcherBuilder.cs │ ├── PostTreeNode.cs │ └── WudiLibBuilderException.cs ├── Dispatcher.cs ├── Exceptions.cs ├── HttpApiClient.cs ├── Message.cs ├── MessageEscapingExtensions.cs ├── MessageInterpolatedStringHandler.cs ├── Posts │ ├── AnonymousInfo.cs │ ├── ApiPostListener.GetPost.cs │ ├── ApiPostListener.cs │ ├── Endpoint.cs │ ├── EventHandler.cs │ ├── GroupBanType.cs │ ├── MessageSource.cs │ ├── Post.Message.cs │ ├── Post.Notice.cs │ ├── Post.cs │ ├── ReceivedMessage.cs │ ├── Responses.cs │ └── SenderInfo.cs ├── Properties │ └── PublishProfiles │ │ └── FolderProfile.pubxml ├── RawMessage.cs ├── Responses │ ├── CqHttpApiResponse.cs │ ├── Friend.cs │ ├── GetMessageResponseData.cs │ ├── GroupInfo.cs │ ├── GroupMemberInfo.cs │ ├── LoginInfo.cs │ ├── SendDiscussMessageResponseData.cs │ ├── SendGroupMessageResponseData.cs │ ├── SendMessageResponseData.cs │ ├── SendPrivateMessageResponseData.cs │ ├── Sex.cs │ └── Status.cs ├── Section.cs ├── SectionMessage.cs ├── SendingMessage.cs ├── Sisters.WudiLib.csproj ├── System.Collections.Generic │ └── CollectionExtensions.cs ├── TODO.md └── Utilities.cs └── Sisters.WudiLibTest ├── Program.cs ├── Properties └── PublishProfiles │ └── FolderProfile.pubxml └── Sisters.WudiLibTest.csproj /.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 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | project.fragment.lock.json 46 | artifacts/ 47 | 48 | *_i.c 49 | *_p.c 50 | *_i.h 51 | *.ilk 52 | *.meta 53 | *.obj 54 | *.pch 55 | *.pdb 56 | *.pgc 57 | *.pgd 58 | *.rsp 59 | *.sbr 60 | *.tlb 61 | *.tli 62 | *.tlh 63 | *.tmp 64 | *.tmp_proj 65 | *.log 66 | *.vspscc 67 | *.vssscc 68 | .builds 69 | *.pidb 70 | *.svclog 71 | *.scc 72 | 73 | # Chutzpah Test files 74 | _Chutzpah* 75 | 76 | # Visual C++ cache files 77 | ipch/ 78 | *.aps 79 | *.ncb 80 | *.opendb 81 | *.opensdf 82 | *.sdf 83 | *.cachefile 84 | *.VC.db 85 | *.VC.VC.opendb 86 | 87 | # Visual Studio profiler 88 | *.psess 89 | *.vsp 90 | *.vspx 91 | *.sap 92 | 93 | # TFS 2012 Local Workspace 94 | $tf/ 95 | 96 | # Guidance Automation Toolkit 97 | *.gpState 98 | 99 | # ReSharper is a .NET coding add-in 100 | _ReSharper*/ 101 | *.[Rr]e[Ss]harper 102 | *.DotSettings.user 103 | 104 | # JustCode is a .NET coding add-in 105 | .JustCode 106 | 107 | # TeamCity is a build add-in 108 | _TeamCity* 109 | 110 | # DotCover is a Code Coverage Tool 111 | *.dotCover 112 | 113 | # NCrunch 114 | _NCrunch_* 115 | .*crunch*.local.xml 116 | nCrunchTemp_* 117 | 118 | # MightyMoose 119 | *.mm.* 120 | AutoTest.Net/ 121 | 122 | # Web workbench (sass) 123 | .sass-cache/ 124 | 125 | # Installshield output folder 126 | [Ee]xpress/ 127 | 128 | # DocProject is a documentation generator add-in 129 | DocProject/buildhelp/ 130 | DocProject/Help/*.HxT 131 | DocProject/Help/*.HxC 132 | DocProject/Help/*.hhc 133 | DocProject/Help/*.hhk 134 | DocProject/Help/*.hhp 135 | DocProject/Help/Html2 136 | DocProject/Help/html 137 | 138 | # Click-Once directory 139 | publish/ 140 | 141 | # Publish Web Output 142 | *.[Pp]ublish.xml 143 | *.azurePubxml 144 | # TODO: Comment the next line if you want to checkin your web deploy settings 145 | # but database connection strings (with potential passwords) will be unencrypted 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 150 | # checkin your Azure Web App publish settings, but sensitive information contained 151 | # in these scripts will be unencrypted 152 | PublishScripts/ 153 | 154 | # NuGet Packages 155 | *.nupkg 156 | # The packages folder can be ignored because of Package Restore 157 | **/packages/* 158 | # except build/, which is used as an MSBuild target. 159 | !**/packages/build/ 160 | # Uncomment if necessary however generally it will be regenerated when needed 161 | #!**/packages/repositories.config 162 | # NuGet v3's project.json files produces more ignoreable files 163 | *.nuget.props 164 | *.nuget.targets 165 | 166 | # Microsoft Azure Build Output 167 | csx/ 168 | *.build.csdef 169 | 170 | # Microsoft Azure Emulator 171 | ecf/ 172 | rcf/ 173 | 174 | # Windows Store app package directories and files 175 | AppPackages/ 176 | BundleArtifacts/ 177 | Package.StoreAssociation.xml 178 | _pkginfo.txt 179 | 180 | # Visual Studio cache files 181 | # files ending in .cache can be ignored 182 | *.[Cc]ache 183 | # but keep track of directories ending in .cache 184 | !*.[Cc]ache/ 185 | 186 | # Others 187 | ClientBin/ 188 | ~$* 189 | *~ 190 | *.dbmdl 191 | *.dbproj.schemaview 192 | *.jfm 193 | *.pfx 194 | *.publishsettings 195 | node_modules/ 196 | orleans.codegen.cs 197 | 198 | # Since there are multiple workflows, uncomment next line to ignore bower_components 199 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 200 | #bower_components/ 201 | 202 | # RIA/Silverlight projects 203 | Generated_Code/ 204 | 205 | # Backup & report files from converting an old project file 206 | # to a newer Visual Studio version. Backup files are not needed, 207 | # because we have git ;-) 208 | _UpgradeReport_Files/ 209 | Backup*/ 210 | UpgradeLog*.XML 211 | UpgradeLog*.htm 212 | 213 | # SQL Server files 214 | *.mdf 215 | *.ldf 216 | 217 | # Business Intelligence projects 218 | *.rdl.data 219 | *.bim.layout 220 | *.bim_*.settings 221 | 222 | # Microsoft Fakes 223 | FakesAssemblies/ 224 | 225 | # GhostDoc plugin setting file 226 | *.GhostDoc.xml 227 | 228 | # Node.js Tools for Visual Studio 229 | .ntvs_analysis.dat 230 | 231 | # Visual Studio 6 build log 232 | *.plg 233 | 234 | # Visual Studio 6 workspace options file 235 | *.opt 236 | 237 | # Visual Studio LightSwitch build output 238 | **/*.HTMLClient/GeneratedArtifacts 239 | **/*.DesktopClient/GeneratedArtifacts 240 | **/*.DesktopClient/ModelManifest.xml 241 | **/*.Server/GeneratedArtifacts 242 | **/*.Server/ModelManifest.xml 243 | _Pvt_Extensions 244 | 245 | # Paket dependency manager 246 | .paket/paket.exe 247 | paket-files/ 248 | 249 | # FAKE - F# Make 250 | .fake/ 251 | 252 | # JetBrains Rider 253 | .idea/ 254 | *.sln.iml 255 | 256 | # CodeRush 257 | .cr/ 258 | 259 | # Python Tools for Visual Studio (PTVS) 260 | __pycache__/ 261 | *.pyc 262 | /Sisters.WudiLib/Sisters.WudiLib.xml 263 | /Sisters.WudiLib.WebSocket/Sisters.WudiLib.WebSocket.xml -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Bleatingsheep 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 |
2 | 3 | # 无敌lib 4 | 为方便 C# 调用酷 Q [HTTP API](https://github.com/richardchien/coolq-http-api) 插件而开发的lib。 5 | 6 | [![#](https://img.shields.io/nuget/v/Sisters.WudiLib.svg)](https://www.nuget.org/packages/Sisters.WudiLib/) 7 | [![#](https://img.shields.io/nuget/v/Sisters.WudiLib.WebSocket.svg)](https://www.nuget.org/packages/Sisters.WudiLib.WebSocket/) 8 | 9 | [查看文档](https://wudilib.b11p.com/) | [文档仓库](https://github.com/b11p/Sisters.WudiLib-docs) 10 | 11 | - Named by [int100](https://github.com/1004121460) 12 | 13 | ## 如何使用 14 | ### 发送消息、调用 API、监听事件 15 | 见:[快速上手](https://wudilib.b11p.com/zhinan/kuaisushangshou.html)。 16 | 17 | ### 发送图片、语音等消息 18 | 见:[进阶 WudiLib](https://wudilib.b11p.com/zhinan/jinjie-wudilib.html) 19 | 20 | ### Token 和 Secret 21 | #### Token 22 | 可以为每个客户端设置不同的 AccessToken。 23 | ```C# 24 | httpApi.AccessToken = "this-is-your-token"; 25 | ``` 26 | 27 | #### Secret 28 | 可以为每个监听实例设置不同的 Secret。 29 | ```C# 30 | listener.SetSecret("this-is-your-secret"); 31 | ``` 32 | 设置后,每次收到上报都会验证上报数据的哈希。如果验证失败,将忽略此次上报。 33 | 34 | ## WebSocket 和其他通信方式 35 | - [正向 WebSocket](https://wudilib.b11p.com/tongxinfangshi/zhengxiang-websocket.html) 36 | - [反向 WebSocket](https://wudilib.b11p.com/tongxinfangshi/fanxiang-websocket.html) 37 | - [扩展其他通信方式](https://wudilib.b11p.com/kuozhan/tongxinfangshi.html) 38 | 39 | 47 | 48 | ## 开发现状 49 | 积极开发中。可以在[路线图](https://wudilib.b11p.com/luxiantu.html)中查看当前开发的目标。也欢迎提出任何 Issue 和 Pull Request。 50 | 51 | ### 小建议 52 | 由于 `Sisters.WudiLib.Message` 和 `Sisters.WudiLib.Posts.Message` 类的类名相同,使用起来有诸多不便,建议您在每个**新**代码文件开头添加下列 `using`: 53 | ```C# 54 | using Message = Sisters.WudiLib.SendingMessage; 55 | using MessageContext = Sisters.WudiLib.Posts.Message; 56 | ``` 57 | 这样,就可以用 `MessageContext` 表示收到的消息上报,用 `Message` 表示要发送的消息了。 58 | 59 | ## 帮助 60 | 如果您需要帮助,请联系 QQ:962549599,注明“WudiLib”和您的称呼。更欢迎直接提出 Issue。 61 | 62 |
-------------------------------------------------------------------------------- /Sisters.WudiLib.Tests/EndpointTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Sisters.WudiLib.Posts; 5 | using Xunit; 6 | 7 | namespace Sisters.WudiLib.Tests 8 | { 9 | public class EndpointTests 10 | { 11 | [Fact] 12 | public void Endpoint_ToString() 13 | { 14 | var endpoints = new Endpoint[] { new PrivateEndpoint(111), new GroupEndpoint(222), new DiscussEndpoint(333) }; 15 | var strings = new[] { "private/111", "group/222", "discuss/333" }; 16 | 17 | for (int i = 0; i < endpoints.Length; i++) 18 | { 19 | Assert.Equal(strings[i], endpoints[i].ToString()); 20 | } 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sisters.WudiLib.Tests/ListenerTests.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Sisters.WudiLib.Posts; 3 | using Xunit; 4 | 5 | namespace Sisters.WudiLib.Tests 6 | { 7 | public class ListenerTests 8 | { 9 | /// 10 | /// 测试文本上报类型中,获取 列表时能否正确地处理转义字符。 11 | /// 12 | [Fact] 13 | public void TextPostTypeSectionsUnescape() 14 | { 15 | string json = @"{""anonymous"":null,""font"":336542616,""group_id"":514661057,""message"":""绑定[CQ:at,qq=962549599] [ Morion ]:测试"",""message_id"":745339,""message_type"":""group"",""post_type"":""message"",""raw_message"":""绑定[CQ:at,qq=962549599] [ Morion ]:测试"",""self_id"":122866607,""sender"":{""age"":21,""card"":""钻石 | 动漫站建不成了"",""nickname"":""ymy😂/pch"",""sex"":""male"",""user_id"":962549599},""sub_type"":""normal"",""time"":1541558577,""user_id"":962549599}"; 16 | var listener = new ApiPostListener(); 17 | IReadOnlyList
sections = null; 18 | listener.MessageEvent += (api, e) => 19 | { 20 | var content = e.Content; 21 | sections = content.Sections; 22 | }; 23 | listener.ProcessPost(json); 24 | 25 | // 26 | Assert.NotNull(sections); 27 | Assert.Equal(3, sections.Count); 28 | 29 | // Section 1 30 | 31 | // Section 2 32 | Assert.Equal>(new SortedDictionary 33 | { 34 | ["qq"] = "962549599" 35 | }, sections[1].Data); 36 | Assert.Equal("at", sections[1].Type); 37 | 38 | // Section 3 39 | // 应该正确转义 " [ Morion ]:测试" 为下面的内容。 40 | Assert.Equal(" [ Morion ]:测试", sections[2].ToString()); 41 | } 42 | 43 | [Fact] 44 | public void Request_MultiFriendRequest() 45 | { 46 | string json = @"{""comment"":""hmmmmmm"",""flag"":""747576"",""post_type"":""request"",""request_type"":""friend"",""self_id"":12345678,""time"":1541601678,""user_id"":87654321}"; 47 | var listener = new ApiPostListener(); 48 | bool invo1 = false, invo2 = false, invo3 = false; 49 | listener.FriendRequestEvent += (api, e) => 50 | { 51 | invo1 = true; 52 | return null; 53 | }; 54 | listener.FriendRequestEvent += (api, e) => 55 | { 56 | invo2 = true; 57 | return new FriendRequestResponse { Approve = false }; 58 | }; 59 | listener.FriendRequestEvent += (api, e) => 60 | { 61 | invo3 = true; 62 | return null; 63 | }; 64 | listener.ProcessPost(json); 65 | Assert.True(invo1); 66 | Assert.True(invo2); 67 | Assert.False(invo3); 68 | } 69 | 70 | [Fact] 71 | public void ArrayMessage_Normal() 72 | { 73 | string json = "{\"anonymous\":null,\"font\":236846192,\"group_id\":123456789,\"message\":[{\"data\":{\"text\":\"去2\"},\"type\":\"text\"}],\"message_id\":282,\"message_type\":\"group\",\"post_type\":\"message\",\"raw_message\":\"去2\",\"self_id\":1131545658,\"sender\":{\"age\":21,\"area\":\"青岛\",\"card\":\"\",\"level\":\"冒泡\",\"nickname\":\"ymy😂/pch\",\"role\":\"owner\",\"sex\":\"female\",\"title\":\"\",\"user_id\":962549599},\"sub_type\":\"normal\",\"time\":1547742375,\"user_id\":962549599}"; 74 | var listener = new ApiPostListener(); 75 | Posts.Message context = null; 76 | listener.MessageEvent += (api, e) => 77 | { 78 | context = e; 79 | }; 80 | listener.ProcessPost(json); 81 | 82 | Assert.IsType(context); 83 | Assert.Equal(1, context.Content.Sections.Count); 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sisters.WudiLib.Tests/SectionTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using Newtonsoft.Json; 5 | using Newtonsoft.Json.Linq; 6 | using Xunit; 7 | 8 | namespace Sisters.WudiLib.Tests 9 | { 10 | public class SectionTests 11 | { 12 | [Fact] 13 | public void CreateAndEqualTest() 14 | { 15 | var atAll = SendingMessage.AtAll(); 16 | Assert.Equal("[CQ:at,qq=all]", atAll.Raw); 17 | 18 | //var section = new Section(Section.MusicType, ("type", "cus"), ("source", "test")); 19 | var section = new Section(Section.MusicType, new Dictionary 20 | { 21 | { "type", "cus" }, 22 | { "source", "test" }, 23 | }); 24 | var section2 = new Section(Section.MusicType, ("source", "test"), ("type", "cus")); 25 | var json = JsonConvert.SerializeObject(section); 26 | var jObj = JsonConvert.DeserializeObject(json); 27 | Assert.Equal(Section.MusicType, jObj["type"].ToObject()); 28 | var desSection = jObj.ToObject
(); 29 | Assert.Equal(section, desSection); 30 | Assert.Equal(section, section2); 31 | Assert.Equal(desSection, section2); 32 | Assert.Equal(section.GetHashCode(), desSection.GetHashCode()); 33 | Assert.Equal(section.GetHashCode(), section2.GetHashCode()); 34 | Assert.Equal(desSection.GetHashCode(), section2.GetHashCode()); 35 | Assert.NotEqual(section.Raw, section2.Raw); 36 | } 37 | 38 | [Fact] 39 | public void Equal_DataOrder() 40 | { 41 | var dic1 = new Dictionary 42 | { 43 | { "para1", "arg1" }, 44 | { "para2", "arg2" }, 45 | }; 46 | var dic2 = new Dictionary 47 | { 48 | { "para2", "arg2" }, 49 | { "para1", "arg1" }, 50 | }; 51 | var section1 = new Section("type", dic1); 52 | var section2 = new Section("type", dic2); 53 | 54 | Assert.Equal(section1, section2); 55 | } 56 | 57 | [Fact] 58 | public void Constructor_ArgNull() 59 | { 60 | Assert.Throws("data", () => new Section("text", ((string, string)[])null)); 61 | Assert.Throws("data", () => new Section("text", (IReadOnlyDictionary)null)); 62 | Assert.Throws("type", () => new Section(null, ("p", "a"))); 63 | Assert.Throws("type", () => new Section(null, new Dictionary())); 64 | } 65 | 66 | [Fact] 67 | public void Constructor_ArgIllegal() 68 | { 69 | new Section("m.0_-", ("key", "value")); 70 | Assert.Throws("type", () => new Section("m.0_- ")); 71 | Assert.Throws("type", () => new Section("")); 72 | Assert.Throws("type", () => new Section(" ")); 73 | Assert.Throws("data", () => new Section("sss", ("!", "123"))); 74 | Assert.Throws("data", () => new Section("sss", ("", "233"))); 75 | Assert.Throws("data", () => new Section("sss", (" ", "233"))); 76 | } 77 | 78 | [Fact] 79 | public void Data_CheckReadOnly() 80 | { 81 | var data = new NotReadOnlyDictionary(new Dictionary 82 | { 83 | ["para"] = "arg", 84 | }); 85 | var section = new Section("some_type", data); 86 | 87 | var count1 = section.Data.Count; 88 | data.Add("keykey", "vvv"); 89 | var count2 = section.Data.Count; 90 | 91 | Assert.Equal(1, count1); 92 | Assert.Equal(count1, count2); 93 | 94 | var dictionary = new Dictionary 95 | { 96 | ["para"] = "arg", 97 | }; 98 | var data2 = new ReadOnlyDictionary(dictionary); 99 | var section2 = new Section("some_type", data2); 100 | dictionary.Add("diff_key", "val"); 101 | var count = section2.Data.Count; 102 | Assert.Equal(1, count); 103 | } 104 | 105 | class NotReadOnlyDictionary : ReadOnlyDictionary 106 | { 107 | public NotReadOnlyDictionary(IDictionary dictionary) : base(dictionary) 108 | { 109 | } 110 | 111 | public void Add(TKey key, TValue value) 112 | { 113 | Dictionary.Add(key, value); 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /Sisters.WudiLib.Tests/SendingMessageTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Text; 5 | using Xunit; 6 | 7 | namespace Sisters.WudiLib.Tests 8 | { 9 | public class SendingMessageTests 10 | { 11 | [Fact] 12 | public void Ctor() 13 | { 14 | string message = "123["; 15 | string raw = "123["; 16 | var msg1 = new SendingMessage(message); 17 | Assert.Equal(raw, msg1.Raw); 18 | //Assert.Equal(message, msg1.ToString()); 19 | 20 | var atSection = new Section("at", ("qq", "all")); 21 | var atMsg = SendingMessage.AtAll(); 22 | Section atMsgSingleSection = atMsg.Sections.Single(); 23 | Assert.Equal(atSection, atMsgSingleSection); 24 | } 25 | 26 | [Theory] 27 | [InlineData("a.jpg", "a.jpg")] 28 | [InlineData("/a b.jpg", "file:///a%20b.jpg")] 29 | [InlineData(@"c:\a.jpg", "file:///c:/a.jpg")] 30 | [InlineData("/dir name%20/file?ww=33", "file:///dir%20name%2520/file%3Fww=33")] 31 | public void LocalImageTests(string path, string expected) 32 | { 33 | var image = SendingMessage.LocalImage(path); 34 | Assert.Equal(new KeyValuePair("file", expected), image.Sections.Single().Data.Single()); 35 | } 36 | 37 | [Fact] 38 | public void Concat() 39 | { 40 | var msg1 = new SendingMessage("1"); 41 | var msg2 = msg1 + "2"; 42 | var msg3 = "3" + msg2; 43 | Assert.IsType(msg2); 44 | Assert.IsType(msg3); 45 | } 46 | 47 | [Fact] 48 | public void ConcatNull() 49 | { 50 | SendingMessage message1 = null, message2 = null; 51 | Assert.NotNull(message1 + message2); 52 | Assert.False((message1 + message2)?.Sections.Count > 0); 53 | 54 | SendingMessage text = "hello"; 55 | Assert.Equal(text.Sections, (message1 + text).Sections); 56 | Assert.Equal(text.Sections, (text + message1).Sections); 57 | } 58 | 59 | public static IEnumerable Interpolated_TestData() 60 | { 61 | var cqCodeMessage = SendingMessage.At(123456789); 62 | var textMessage = "hello"; 63 | var imageMessage = SendingMessage.LocalImage("/a.jpg"); 64 | var multipleSegmentMessage = SendingMessage.At(123456789) + "hello" + SendingMessage.LocalImage("/a.jpg"); 65 | { 66 | yield return new object[] { SendingMessage.FromInterpolated($"{1}"), "1" }; 67 | yield return new object[] { SendingMessage.FromInterpolated($"{cqCodeMessage},你好"), cqCodeMessage + ",你好" }; 68 | yield return new object[] { SendingMessage.FromInterpolated($"{multipleSegmentMessage},你好"), multipleSegmentMessage + ",你好" }; 69 | yield return new object[] { SendingMessage.FromInterpolated($"{cqCodeMessage}{textMessage}"), cqCodeMessage + textMessage }; 70 | yield return new object[] { SendingMessage.FromInterpolated($"{textMessage}{cqCodeMessage},[]& {23}"), textMessage + cqCodeMessage + ",[]& " + 23.ToString() }; 71 | } 72 | } 73 | 74 | [Theory] 75 | [MemberData(nameof(Interpolated_TestData))] 76 | public void Interpolated(SendingMessage expected, SendingMessage actual) 77 | { 78 | Assert.Equal(expected.Raw, actual.Raw); 79 | Assert.Equal(expected.Sections.Count, actual.Sections.Count); 80 | for (int i = 0; i < expected.Sections.Count; i++) 81 | { 82 | Assert.Equal(expected.Sections[i], actual.Sections[i]); 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /Sisters.WudiLib.Tests/Sisters.WudiLib.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp6.0 5 | 6 | false 7 | 8 | 9 | 10 | 11 | 12 | 13 | all 14 | runtime; build; native; contentfiles; analyzers 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket.Test/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Sisters.WudiLib.Posts; 5 | using Sisters.WudiLib.WebSocket.Reverse; 6 | 7 | namespace Sisters.WudiLib.WebSocket.Test 8 | { 9 | internal class Program 10 | { 11 | private static void ConfigListener(ApiPostListener cqWebSocketEvent) 12 | { 13 | _ = Task.Run(async () => 14 | { 15 | while (true) 16 | { 17 | await Task.Delay(1000); 18 | Console.WriteLine("Available: {0}, Listening {1}", (cqWebSocketEvent as dynamic).IsAvailable, (cqWebSocketEvent as dynamic).IsListening); 19 | } 20 | }); 21 | cqWebSocketEvent.MessageEvent += async (api, e) => 22 | { 23 | Console.WriteLine(e.Content.Raw); 24 | Console.WriteLine(api is null); 25 | await api.SendMessageAsync(e.Endpoint, "Response 1" + SendingMessage.LocalImage(@"C:\Users\yinmi\Pictures\bad(Y)(auto_scale)(Level3)(width 2000).jpg")); 26 | if (e is GroupMessage groupMessage) 27 | { 28 | await api.GetGroupMemberInfoAsync(groupMessage.GroupId, e.UserId); 29 | } 30 | await api.SendMessageAsync(e.Endpoint, "Response 2" + SendingMessage.LocalImage(@"C:\Users\yinmi\Pictures\karen.jpg")); 31 | }; 32 | cqWebSocketEvent.FriendRequestEvent += (api, e) => true; 33 | cqWebSocketEvent.GroupInviteEvent += (api, e) => true; 34 | cqWebSocketEvent.AnonymousMessageEvent += (api, e) => 35 | { 36 | Console.WriteLine("id|name|flag:{0}|{1}|{2}", e.Anonymous.Id, e.Anonymous.Name, e.Anonymous.Flag); 37 | api.BanMessageSource(e.GroupId, e.Source, 1); 38 | }; 39 | } 40 | 41 | private static async Task TestPositive() 42 | { 43 | var cqWebSocketEvent = new CqHttpWebSocketEvent("ws://[::1]:6700/event", ""); 44 | var httpApiClient = new CqHttpWebSocketApiClient("ws://[::1]:6700/api", ""); 45 | cqWebSocketEvent.ApiClient = httpApiClient; 46 | ConfigListener(cqWebSocketEvent); 47 | 48 | await Task.Delay(TimeSpan.FromSeconds(3)); 49 | var cancellationTokenSource = new CancellationTokenSource(); 50 | await cqWebSocketEvent.StartListen(cancellationTokenSource.Token); 51 | Console.ReadLine(); 52 | cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(2)); 53 | await Task.Delay(TimeSpan.FromSeconds(5)); 54 | cancellationTokenSource.Dispose(); 55 | cancellationTokenSource = new CancellationTokenSource(); 56 | await cqWebSocketEvent.StartListen(cancellationTokenSource.Token); 57 | await Task.Delay(-1); 58 | } 59 | 60 | private static async Task TestNegative() 61 | { 62 | var server = new Reverse.ReverseWebSocketServer("http://localhost:9191"); 63 | server.SetListenerAuthenticationAndConfiguration(r => Task.FromResult>((l, _) => ConfigListener(l))); 64 | 65 | var cancellationTokenSource = new CancellationTokenSource(); 66 | server.Start(cancellationTokenSource.Token); 67 | Console.ReadLine(); 68 | 69 | cancellationTokenSource.CancelAfter(TimeSpan.FromSeconds(2)); 70 | await Task.Delay(TimeSpan.FromSeconds(5)); 71 | cancellationTokenSource.Dispose(); 72 | 73 | cancellationTokenSource = new CancellationTokenSource(); 74 | server.Start(cancellationTokenSource.Token); 75 | await Task.Delay(-1); 76 | } 77 | 78 | private static Task Main(string[] args) 79 | { 80 | return TestNegative(); 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket.Test/Sisters.WudiLib.WebSocket.Test.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | netcoreapp3.1 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/CqHttpWebSocketApiClient.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Text; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Newtonsoft.Json; 7 | using Newtonsoft.Json.Linq; 8 | using static Sisters.WudiLib.WebSocket.WebSocketUtility; 9 | 10 | namespace Sisters.WudiLib.WebSocket 11 | { 12 | /// 13 | /// 实现通过正向 WebSocket 访问 OneBot API 的类。 14 | /// 15 | public class CqHttpWebSocketApiClient : HttpApiClient, IDisposable 16 | { 17 | private static readonly JsonSerializerSettings s_jsonSerializerSettings = new JsonSerializerSettings(); 18 | 19 | private static int GetRandomInt32() 20 | { 21 | Span span = stackalloc int[1]; 22 | System.Security.Cryptography.RandomNumberGenerator.Fill( 23 | System.Runtime.InteropServices.MemoryMarshal.AsBytes(span)); 24 | return span[0]; 25 | } 26 | 27 | internal CqHttpWebSocketApiClient(IRequestSender requestSender) 28 | { 29 | requestSender.SocketDisconnected += () => 30 | { 31 | var nSource = new CancellationTokenSource(); 32 | var oldSource = Interlocked.Exchange(ref _failedSource, nSource); 33 | oldSource.Cancel(); 34 | }; 35 | requestSender.OnResponse = (_, jObject) => OnResponse(jObject); 36 | _manager = requestSender; 37 | } 38 | 39 | /// 40 | /// 初始化实例,可以被子类调用。 41 | /// 42 | protected CqHttpWebSocketApiClient() : base("http://wsdefault/") 43 | { 44 | _manager = new PositiveWebSocketManager(() => CreateUri(Uri, AccessToken)) 45 | { 46 | OnResponse = (_, jObject) => OnResponse(jObject), 47 | AutoReconnect = false, 48 | }; 49 | _manager.SocketDisconnected += () => 50 | { 51 | var nSource = new CancellationTokenSource(); 52 | var oldSource = Interlocked.Exchange(ref _failedSource, nSource); 53 | oldSource.Cancel(); 54 | }; 55 | } 56 | 57 | /// 58 | /// 从给定 WebSocket URL 创建实例。 59 | /// 60 | /// 正向 WS 监听地址(以 ws:// 或 wss:// 开头)。 61 | public CqHttpWebSocketApiClient(string uri) : this() => Uri = uri; 62 | 63 | /// 64 | /// 从给定 WebSocket URL 和 Access Token 创建实例。 65 | /// 66 | /// 正向 WS 监听地址(以 ws:// 或 wss:// 开头)。 67 | /// Access Token。 68 | public CqHttpWebSocketApiClient(string uri, string accessToken) 69 | : this(uri) => AccessToken = accessToken; 70 | 71 | /// 72 | /// 获取 uri。 73 | /// 74 | public string Uri { get; } 75 | 76 | /// 77 | /// 获取 WebSocket 的连接地址。 78 | /// 79 | public override string ApiAddress 80 | { 81 | get => Uri; 82 | set => throw new InvalidOperationException("基于正向 WebSocket 的客户端无法在运行时改变连接地址。"); 83 | } 84 | 85 | /// 86 | /// 获取或设置 AccessToken。将在下次自动重连时生效。 87 | /// 88 | public override string AccessToken { get; set; } 89 | 90 | #region Echo 91 | private int _currentEcho = GetRandomInt32(); 92 | /// 93 | /// 获取下一个可用的 Echo 值。 94 | /// 95 | /// 96 | protected int GetNextEcho() => Interlocked.Increment(ref _currentEcho); 97 | #endregion 98 | 99 | #region Call API and get response 100 | 101 | private TimeSpan _timeOut = TimeSpan.FromMinutes(1); 102 | private CancellationTokenSource _failedSource = new CancellationTokenSource(); 103 | 104 | /// 105 | /// 等待 API 响应的超时值。 106 | /// 107 | public TimeSpan TimeOut 108 | { 109 | get => _timeOut; 110 | set => _timeOut = value > TimeSpan.Zero || value == TimeSpan.FromMilliseconds(-1) 111 | ? value 112 | : throw new ArgumentOutOfRangeException(nameof(value), value, "TimeOut must be positive, or -1 millseconds."); 113 | } 114 | 115 | private readonly ConcurrentDictionary _responses = new(); 116 | 117 | /// 118 | /// Note: swallows exceptions. 119 | /// 120 | /// 121 | /// Success. 122 | protected virtual bool OnResponse(JObject r) 123 | { 124 | try 125 | { 126 | var echo = r["echo"].ToObject(); 127 | if (_responses.TryGetValue(echo, out var wsResponse)) 128 | { 129 | wsResponse.Data = r; 130 | wsResponse.Lock.Release(); 131 | return true; 132 | } 133 | else 134 | { 135 | return false; 136 | } 137 | } 138 | catch (Exception e) 139 | { 140 | return false; 141 | } 142 | } 143 | 144 | /// 145 | /// 用未包装的方式调用 API。 146 | /// 147 | /// 操作。 148 | /// 参数数据。 149 | /// 响应结果。 150 | protected override async Task CallRawJObjectAsync(string action, object data) 151 | { 152 | var jObject = new JObject(); 153 | var echo = GetNextEcho(); 154 | jObject["echo"] = echo; 155 | jObject["action"] = action; 156 | jObject["params"] = data is JObject j ? j : JObject.FromObject(data); 157 | var response = new WebSocketResponse 158 | { 159 | CancellationToken = _failedSource.Token, 160 | }; 161 | using var l = response.Lock; 162 | if (!_responses.TryAdd(echo, response)) 163 | throw new ApiAccessException("使用 WebSocket 访问 API 时出现并发错误。", null); 164 | try 165 | { 166 | await CallRawAsync(action, jObject.ToString(Formatting.None)).ConfigureAwait(false); 167 | if (!await l.WaitAsync(TimeOut, response.CancellationToken).ConfigureAwait(false)) 168 | throw new ApiAccessException("等待 WebSocket 响应超时。"); 169 | return response.Data; 170 | } 171 | finally 172 | { 173 | _responses.TryRemove(echo, out _); 174 | } 175 | } 176 | #endregion 177 | 178 | #region Send request and manage WebSocket 179 | private readonly CancellationTokenSource _disposeSource = new(); 180 | private readonly IRequestSender _manager; 181 | 182 | /// 183 | /// 发送调用消息,被 调用 184 | /// 185 | /// 操作。 186 | /// 参数 json。 187 | /// 由于无法直接获取响应,始终为空字符串。 188 | protected override async Task CallRawAsync(string action, string json) 189 | { 190 | await _manager.SendAsync(Encoding.UTF8.GetBytes(json), _disposeSource.Token).ConfigureAwait(false); 191 | return string.Empty; 192 | } 193 | 194 | /// 195 | /// 析构此对象。 196 | /// 197 | public void Dispose() 198 | { 199 | _disposeSource.Cancel(); 200 | (_manager as IDisposable)?.Dispose(); 201 | } 202 | #endregion 203 | 204 | #region Embedded class 205 | private sealed class WebSocketResponse 206 | { 207 | public SemaphoreSlim Lock { get; } = new SemaphoreSlim(0, 1); 208 | public JObject Data { get; set; } 209 | public CancellationToken CancellationToken { get; set; } 210 | } 211 | #endregion 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/CqHttpWebSocketEvent.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using Newtonsoft.Json.Linq; 6 | using Sisters.WudiLib.Posts; 7 | 8 | namespace Sisters.WudiLib.WebSocket 9 | { 10 | /// 11 | /// 事件上报的 WebSocket 客户端。请注意,WebSocket 客户端暂不支持直接通过返回值响应。 12 | /// 13 | public class CqHttpWebSocketEvent : ApiPostListener, IDisposable 14 | { 15 | /// 16 | /// 当前连接的 WebSocket 客户端。如果发生断线重连,则可能改变。 17 | /// 18 | protected System.Net.WebSockets.WebSocket WebSocket => _manager.WebSocket; 19 | 20 | /// 21 | /// 引发 。 22 | /// 23 | /// 不支持。 24 | public override string PostAddress 25 | { 26 | get => throw new NotSupportedException("WebSocket 不支持读取 PostAddress。"); 27 | set => throw new NotSupportedException("WebSocket 不支持设置 PostAddress。"); 28 | } 29 | 30 | /// 31 | /// 获取 uri。 32 | /// 33 | public string Uri { get; } 34 | 35 | /// 36 | /// 指示当前是否已启动监听。若要检查当前是否可用,请使用 属性。 37 | /// 38 | public override bool IsListening => _manager.IsRunning; 39 | 40 | /// 41 | /// 获取当前是否能收到上报事件。注意自动重连过程中此项为 false,但无法再次通过 连接。 42 | /// 43 | public virtual bool IsAvailable => _manager.IsRunning && _manager.IsAvailable; 44 | 45 | /// 46 | /// 构造通过 WebSocket 获取上报的监听客户端。 47 | /// 48 | /// ws:// 或者 wss:// 开头的 uri,用于连接 WebSocket。 49 | public CqHttpWebSocketEvent(string uri) : this(uri, string.Empty) 50 | { } 51 | 52 | /// 53 | /// 构造通过 WebSocket 获取上报的监听客户端。 54 | /// 55 | /// ws:// 或者 wss:// 开头的 uri,用于连接 WebSocket。 56 | /// Access Token. 57 | public CqHttpWebSocketEvent(string uri, string accessToken) 58 | { 59 | _manager = new PositiveWebSocketManager(uri, accessToken) 60 | { 61 | OnEvent = (bytes, jObject) => Task.Run(() => OnEventAsync(bytes, jObject)), 62 | AutoReconnect = true, 63 | }; 64 | } 65 | 66 | /// 67 | /// 开始从 WebSocket 监听上报。 68 | /// 69 | /// 连接失败等。 70 | public override void StartListen() 71 | => _ = StartListen(default(CancellationToken)); 72 | 73 | /// 74 | /// 开始从 WebSocket 监听上报。 75 | /// 76 | /// 一个 应该被使用,以通知此操作应被取消。 77 | /// 连接失败等。 78 | public async Task StartListen(CancellationToken cancellationToken) 79 | => await _manager.ConnectAsync(cancellationToken).ConfigureAwait(false); 80 | 81 | private async Task OnEventAsync(byte[] eventArray, JObject eventObject) 82 | { 83 | ForwardAsync(eventArray, Encoding.UTF8, null); 84 | 85 | try 86 | { 87 | await this.ProcessWSMessageAsync(eventObject).ConfigureAwait(false); 88 | } 89 | catch (Exception e) 90 | { 91 | LogException(e, Encoding.UTF8.GetString(eventArray)); 92 | } 93 | } 94 | 95 | #region Manage WebSocket 96 | private readonly CancellationTokenSource _disposeSource = new(); 97 | private readonly PositiveWebSocketManager _manager; 98 | 99 | /// 100 | /// Disconnects from remote and disposes this object. 101 | /// 102 | public void Dispose() 103 | { 104 | _disposeSource.Cancel(); 105 | _manager.Dispose(); 106 | } 107 | #endregion 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/HttpApiClientExtentions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Newtonsoft.Json.Linq; 4 | using Sisters.WudiLib.Posts; 5 | 6 | namespace Sisters.WudiLib.WebSocket 7 | { 8 | /// 9 | /// 的扩展方法,主要用来响应请求。 10 | /// 11 | public static class HttpApiClientExtentions 12 | { 13 | /// 14 | /// 处理加好友请求。 15 | /// 16 | /// HTTP API 客户端。 17 | /// 上报的加好友请求。 18 | /// 响应。 19 | /// 网络错误等。 20 | /// 某个参数为 null 21 | /// 是否成功 22 | public static Task HandleFriendRequestAsync(this HttpApiClient httpApiClient, FriendRequest request, FriendRequestResponse response) 23 | { 24 | if (httpApiClient == null) 25 | { 26 | throw new ArgumentNullException(nameof(httpApiClient)); 27 | } 28 | 29 | if (request == null) 30 | { 31 | throw new ArgumentNullException(nameof(request)); 32 | } 33 | 34 | if (response == null) 35 | { 36 | throw new ArgumentNullException(nameof(response)); 37 | } 38 | 39 | var data = JObject.FromObject(response); 40 | data["flag"] = request.Flag; 41 | return httpApiClient.HandleFriendRequestInternalAsync(data); 42 | } 43 | 44 | internal static Task HandleFriendRequestInternalAsync(this HttpApiClient httpApiClient, JObject data) 45 | => httpApiClient.CallAsync("set_friend_add_request", data); 46 | 47 | /// 48 | /// 处理加群请求/邀请。 49 | /// 50 | /// HTTP API 客户端。 51 | /// 上报的加群邀请或请求。 52 | /// 响应。 53 | /// 网络错误等。 54 | /// 某个参数为 null 55 | /// 是否成功 56 | public static Task HandleGroupRequestAsync(this HttpApiClient httpApiClient, GroupRequest request, GroupRequestResponse response) 57 | { 58 | if (httpApiClient == null) 59 | { 60 | throw new ArgumentNullException(nameof(httpApiClient)); 61 | } 62 | 63 | if (request == null) 64 | { 65 | throw new ArgumentNullException(nameof(request)); 66 | } 67 | 68 | if (response == null) 69 | { 70 | throw new ArgumentNullException(nameof(response)); 71 | } 72 | 73 | var data = JObject.FromObject(request); 74 | var responseData = JObject.FromObject(response); 75 | data.Merge(responseData); 76 | return httpApiClient.HandleGroupRequestInternalAsync(data); 77 | } 78 | 79 | internal static Task HandleGroupRequestInternalAsync(this HttpApiClient httpApiClient, JObject data) 80 | => httpApiClient.CallAsync("set_group_add_request", data); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/IEventReceiver.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json.Linq; 3 | 4 | namespace Sisters.WudiLib.WebSocket 5 | { 6 | /// 7 | /// 表示实现了接收事件功能的接口。 8 | /// 9 | public interface IEventReceiver 10 | { 11 | /// 12 | /// 获取或设置事件处理器。 13 | /// 14 | Action OnEvent { get; set; } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/IRequestSender.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using System.Threading.Tasks; 4 | using Newtonsoft.Json.Linq; 5 | 6 | namespace Sisters.WudiLib.WebSocket 7 | { 8 | /// 9 | /// 表示实现了发送请求和接收响应的接口。 10 | /// 11 | public interface IRequestSender 12 | { 13 | /// 14 | /// 收到响应时的处理器。 15 | /// 16 | Action OnResponse { get; set; } 17 | /// 18 | /// Used for cleanup. 19 | /// 20 | event Action SocketDisconnected; 21 | 22 | /// 23 | /// 发送请求。 24 | /// 25 | /// 请求内容。应为 JSON 字符串编码为 UTF-8 的数组。 26 | /// 取消令牌。可能会被实现类保存以用于整个连接。 27 | /// 发送请求任务。 28 | Task SendAsync(ArraySegment buffer, CancellationToken cancellationToken = default); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/PositiveWebSocketManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net.WebSockets; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using static Sisters.WudiLib.WebSocket.WebSocketUtility; 7 | 8 | namespace Sisters.WudiLib.WebSocket 9 | { 10 | internal class PositiveWebSocketManager : WebSocketManager, IDisposable 11 | { 12 | private CancellationToken _cancellationToken; 13 | private readonly SemaphoreSlim _connectSemaphore = new(1, 1); 14 | private Func _getUri; 15 | 16 | internal Func GetUri 17 | { 18 | get => _getUri; 19 | set => _getUri = value ?? throw new ArgumentNullException(nameof(value)); 20 | } 21 | 22 | public PositiveWebSocketManager(Func getUri) 23 | => GetUri = getUri; 24 | 25 | public PositiveWebSocketManager(string url, string accessToken) 26 | : this(() => CreateUri(url, accessToken)) 27 | { } 28 | 29 | internal bool AutoReconnect { get; set; } = true; 30 | 31 | /// 32 | /// 连接远程正向 WebSocket 服务器。 33 | /// 34 | /// 取消令牌。 35 | /// 连接任务。连接成功后结束。 36 | /// 已经建立了 WebSocket 连接,无法再次建立。 37 | public async Task ConnectAsync(CancellationToken cancellationToken) 38 | { 39 | await _connectSemaphore.WaitAsync().ConfigureAwait(false); 40 | try 41 | { 42 | if (WebSocket != null && !_cancellationToken.IsCancellationRequested) 43 | { 44 | // TODO: 当前抛出的异常会被包裹在 Task 中,应尽可能直接抛出。 45 | throw new InvalidOperationException("已经建立了 WebSocket 连接,无法再次建立。"); 46 | } 47 | _cancellationToken = cancellationToken; 48 | await InitializeWebSocketAsync(cancellationToken).ConfigureAwait(false); 49 | } 50 | finally 51 | { 52 | _connectSemaphore.Release(); 53 | } 54 | _listenTask = RunListeningTask(cancellationToken); 55 | } 56 | 57 | /// 58 | /// Trys to connect to WS server. Does not throw an exception if has connected. 59 | /// Still throws if connection fails. 60 | /// 61 | /// 62 | /// Cancellation token. Will be saved and used only when creating new instance of 63 | /// . 64 | /// 65 | protected override async Task GetWebSocketAsync(CancellationToken cancellationToken) 66 | { 67 | // 除了此方法和上面的 ConnectAsync 方法,还有 ReconnectIfNecessaryAsync 68 | // 方法也调用了 InitializeWebSocketAsync。但是 ReconnectIfNecessaryAsync 69 | // 没有使用 _connectSemaphore 控制并发。这是因为 ReconnectIfNecessaryAsync 只会在重连时调用, 70 | // 而重连全过程不会满足此处的进入条件,故不会破坏线程安全。 71 | if (WebSocket != null && !_cancellationToken.IsCancellationRequested) 72 | {// ignore 73 | return WebSocket; 74 | } 75 | System.Net.WebSockets.WebSocket ret; 76 | await _connectSemaphore.WaitAsync().ConfigureAwait(false); 77 | try 78 | { 79 | if (WebSocket != null && !_cancellationToken.IsCancellationRequested) 80 | {// ignore 81 | return WebSocket; 82 | } 83 | _cancellationToken = cancellationToken; 84 | ret = await InitializeWebSocketAsync(cancellationToken).ConfigureAwait(false); 85 | } 86 | finally 87 | { 88 | _connectSemaphore.Release(); 89 | } 90 | _listenTask = RunListeningTask(cancellationToken); 91 | return ret; 92 | } 93 | 94 | protected override async Task RunListeningTask(CancellationToken cancellationToken) 95 | { 96 | byte[] buffer = new byte[1024]; 97 | var ms = new MemoryStream(); 98 | while (true) 99 | { 100 | ThrowIfCanceledOrDisposed(cancellationToken); 101 | byte[] eventArray; 102 | try 103 | { 104 | var receiveResult = await WebSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); 105 | ms.Write(buffer, 0, receiveResult.Count); 106 | if (!receiveResult.EndOfMessage) continue; 107 | eventArray = ms.ToArray(); 108 | } 109 | catch (Exception) 110 | { 111 | if (!AutoReconnect && !IsAvailable) 112 | { 113 | // 当出现异常后确认了不可用,并且不需要自动重连时,回收资源, 114 | // 然后退出。 115 | var ws = WebSocket; 116 | WebSocket = null; 117 | (ws as IDisposable)?.Dispose(); 118 | OnSocketDisconnected(); 119 | break; 120 | } 121 | await ReconnectIfNecessaryAsync(cancellationToken).ConfigureAwait(false); 122 | ms = new MemoryStream(); 123 | continue; 124 | } 125 | 126 | try 127 | { 128 | Dispatch(eventArray); 129 | } 130 | #pragma warning disable RCS1075 // Avoid empty catch clause that catches System.Exception. 131 | catch (Exception) 132 | #pragma warning restore RCS1075 // Avoid empty catch clause that catches System.Exception. 133 | { 134 | // ignored 135 | } 136 | ms = new MemoryStream(); 137 | } 138 | } 139 | 140 | #region management 141 | private async Task InitializeWebSocketAsync(CancellationToken cancellationToken) 142 | { 143 | ThrowIfCanceledOrDisposed(cancellationToken); 144 | ClientWebSocket clientWebSocket = await CreateWebSocketAsync(GetUri(), cancellationToken).ConfigureAwait(false); 145 | WebSocket = clientWebSocket; 146 | return clientWebSocket; 147 | } 148 | 149 | private async Task ReconnectIfNecessaryAsync(CancellationToken cancellationToken) 150 | { 151 | ThrowIfCanceledOrDisposed(cancellationToken); 152 | try 153 | { 154 | if (!IsAvailable) 155 | { 156 | (WebSocket as IDisposable)?.Dispose(); 157 | await InitializeWebSocketAsync(cancellationToken).ConfigureAwait(false); 158 | } 159 | } 160 | catch (Exception) 161 | { 162 | ThrowIfCanceledOrDisposed(cancellationToken); 163 | } 164 | } 165 | 166 | internal static async Task CreateWebSocketAsync(Uri uri, CancellationToken cancellationToken) 167 | { 168 | var clientWebSocket = new ClientWebSocket(); 169 | await clientWebSocket.ConnectAsync(uri, cancellationToken).ConfigureAwait(false); 170 | return clientWebSocket; 171 | } 172 | 173 | #endregion 174 | 175 | #region utils 176 | private void ThrowIfCanceledOrDisposed(CancellationToken cancellationToken) 177 | { 178 | cancellationToken.ThrowIfCancellationRequested(); 179 | if (_disposedValue) 180 | throw new ObjectDisposedException(nameof(PositiveWebSocketManager), "此对象已被 dispose。"); 181 | } 182 | #endregion 183 | 184 | #region dispose 185 | private bool _disposedValue; 186 | 187 | protected virtual void Dispose(bool disposing) 188 | { 189 | if (!_disposedValue) 190 | { 191 | _disposedValue = true; 192 | if (disposing) 193 | { 194 | (WebSocket as IDisposable)?.Dispose(); 195 | } 196 | } 197 | } 198 | 199 | public void Dispose() 200 | { 201 | // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method 202 | Dispose(disposing: true); 203 | GC.SuppressFinalize(this); 204 | } 205 | #endregion 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/Properties/PublishProfiles/FolderProfile.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | FileSystem 8 | Release 9 | Any CPU 10 | netcoreapp2.1 11 | bin\Release\netcoreapp2.1\publish\ 12 | 13 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/Reverse/NegativeWebSocketEventListener.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Concurrent; 3 | using System.Text; 4 | using System.Threading.Tasks; 5 | using Newtonsoft.Json.Linq; 6 | using Sisters.WudiLib.Posts; 7 | 8 | namespace Sisters.WudiLib.WebSocket.Reverse 9 | { 10 | /// 11 | /// 反向 WebSocket 连接的事件监听器。 12 | /// 13 | public class NegativeWebSocketEventListener : ApiPostListener 14 | { 15 | private NegativeWebSocketManager _negativeWebSocketManager; 16 | private bool _isStarted; 17 | private BlockingCollection<(byte[] data, JObject jObject)> _backlogEventBag = new(new ConcurrentQueue<(byte[], JObject)>()); 18 | 19 | internal void SetManager(NegativeWebSocketManager negativeWebSocketManager) 20 | { 21 | negativeWebSocketManager.OnEvent = (data, jObject) => 22 | { 23 | try 24 | { 25 | // TryAdd may still throw. 26 | // Hence, try-catch block is necessary. 27 | _backlogEventBag.Add((data, jObject)); 28 | } 29 | catch (Exception) 30 | { 31 | // When it throws, the listener must have been started. 32 | _ = OnEventAsync(data, jObject); 33 | } 34 | }; 35 | _negativeWebSocketManager = negativeWebSocketManager; 36 | _negativeWebSocketManager.SocketDisconnected += () => SocketDisconnected?.Invoke(); 37 | } 38 | 39 | private async Task OnEventAsync(byte[] data, JObject jObject) 40 | { 41 | ForwardAsync(data, Encoding.UTF8, null); 42 | try 43 | { 44 | await this.ProcessWSMessageAsync(jObject).ConfigureAwait(false); 45 | } 46 | catch (Exception e) 47 | { 48 | LogException(e, Encoding.UTF8.GetString(data)); 49 | } 50 | } 51 | 52 | /// 53 | /// 当反向 WebSocket 连接断开时触发。 54 | /// 55 | public event Action SocketDisconnected; 56 | 57 | /// 58 | /// 引发 。 59 | /// 60 | /// 不支持。 61 | public sealed override string PostAddress 62 | { 63 | get => throw new NotSupportedException("WebSocket 不支持读取 PostAddress。"); 64 | set => throw new NotSupportedException("WebSocket 不支持设置 PostAddress。"); 65 | } 66 | 67 | /// 68 | /// 指示当前是否已启动监听。若要检查当前是否可用,请使用 属性。 69 | /// 70 | public sealed override bool IsListening => _isStarted && _negativeWebSocketManager?.IsRunning == true; 71 | 72 | /// 73 | /// 指示当前是否还可以收到事件上报。 74 | /// 75 | public bool IsAvailable => IsListening && _negativeWebSocketManager?.IsAvailable == true; 76 | 77 | /// 78 | /// 引发 。 79 | /// 80 | /// 81 | public sealed override void StartListen() 82 | => throw new NotSupportedException("反向 WebSocket 在建立时即已开始监听,无法手动设置。"); 83 | 84 | internal void StartProcessEventInternal() 85 | { 86 | _isStarted = true; 87 | _negativeWebSocketManager.OnEvent = (bytes, jObject) => _ = OnEventAsync(bytes, jObject); 88 | _backlogEventBag.CompleteAdding(); 89 | using var bag = _backlogEventBag; 90 | while (bag.TryTake(out var tuple)) 91 | { 92 | var (data, jObject) = tuple; 93 | _ = OnEventAsync(data, jObject); 94 | } 95 | _backlogEventBag = null; 96 | } 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/Reverse/NegativeWebSocketManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace Sisters.WudiLib.WebSocket.Reverse 7 | { 8 | /// 9 | /// 处理单个 WebSocket 连接的被动连接管理器。 10 | /// 11 | internal class NegativeWebSocketManager : WebSocketManager, IDisposable 12 | { 13 | private readonly object _startLock = new object(); 14 | 15 | public NegativeWebSocketManager(System.Net.WebSockets.WebSocket webSocket) 16 | => WebSocket = webSocket; 17 | 18 | /// 19 | /// 开始处理反向 WS 连接消息。 20 | /// 21 | /// 取消令牌。 22 | /// 已经开始执行,无法再次开始。 23 | public void Start(CancellationToken cancellationToken) 24 | { 25 | lock (_startLock) 26 | { 27 | _listenTask = _listenTask is null 28 | ? RunListeningTask(cancellationToken) 29 | : throw new InvalidOperationException("已经开始执行。"); 30 | } 31 | } 32 | 33 | protected override Task GetWebSocketAsync(CancellationToken cancellationToken) 34 | => Task.FromResult(WebSocket); 35 | 36 | protected async override Task RunListeningTask(CancellationToken cancellationToken) 37 | { 38 | byte[] buffer = new byte[1024]; 39 | var ms = new MemoryStream(); 40 | while (true) 41 | { 42 | cancellationToken.ThrowIfCancellationRequested(); 43 | byte[] eventArray; 44 | try 45 | { 46 | var receiveResult = await WebSocket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false); 47 | ms.Write(buffer, 0, receiveResult.Count); 48 | if (!receiveResult.EndOfMessage) 49 | continue; 50 | eventArray = ms.ToArray(); 51 | } 52 | catch (Exception) 53 | { 54 | if (!IsAvailable) 55 | { 56 | // 当出现异常后确认了不可用,被动管理无法重连。 57 | // 回收资源,然后退出。 58 | (WebSocket as IDisposable)?.Dispose(); 59 | OnSocketDisconnected(); 60 | break; 61 | } 62 | ms = new MemoryStream(); 63 | continue; 64 | } 65 | 66 | try 67 | { 68 | Dispatch(eventArray); 69 | } 70 | #pragma warning disable RCS1075 // Avoid empty catch clause that catches System.Exception. 71 | catch (Exception) 72 | #pragma warning restore RCS1075 // Avoid empty catch clause that catches System.Exception. 73 | { 74 | // ignored 75 | } 76 | ms = new MemoryStream(); 77 | } 78 | } 79 | 80 | public void Dispose() => WebSocket?.Dispose(); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/Reverse/ReverseConnectionInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Specialized; 3 | using System.Net; 4 | using System.Net.WebSockets; 5 | 6 | namespace Sisters.WudiLib.WebSocket.Reverse 7 | { 8 | /// 9 | /// 反向 WebSocket 连接信息。 10 | /// 11 | internal class ReverseConnectionInfo : IDisposable 12 | { 13 | internal ReverseConnectionInfo(HttpListenerWebSocketContext webSocketContext, HttpListenerRequest request, long selfId, NegativeWebSocketEventListener listener) 14 | { 15 | SelfId = selfId; 16 | WebSocketManager = new NegativeWebSocketManager(webSocketContext.WebSocket); 17 | HttpApiClient = new CqHttpWebSocketApiClient(WebSocketManager); 18 | ApiPostListener = listener; 19 | ApiPostListener.ApiClient = HttpApiClient; 20 | ApiPostListener.SetManager(WebSocketManager); 21 | RequestHeaders = request.Headers; 22 | QueryString = request.QueryString; 23 | RemoteAddress = request.RemoteEndPoint.Address; 24 | } 25 | 26 | internal NegativeWebSocketManager WebSocketManager { get; set; } 27 | public long SelfId { get; } 28 | public NegativeWebSocketEventListener ApiPostListener { get; } 29 | public HttpApiClient HttpApiClient { get; } 30 | public NameValueCollection RequestHeaders { get; } 31 | public NameValueCollection QueryString { get; } 32 | public IPAddress RemoteAddress { get; } 33 | 34 | public void Dispose() => WebSocketManager?.Dispose(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/Reverse/ReverseWebSocketServer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | 7 | namespace Sisters.WudiLib.WebSocket.Reverse 8 | { 9 | /// 10 | /// 反向 WebSocket 服务器。 11 | /// 12 | public sealed class ReverseWebSocketServer 13 | { 14 | private HttpListener _httpListener = new(); 15 | private readonly SemaphoreSlim _listeningSemaphore = new SemaphoreSlim(1, 1); 16 | 17 | private Func>> _authentication = _ => Task.FromResult>((_, _) => { }); 18 | private Func _createListener = _ => new NegativeWebSocketEventListener(); 19 | private readonly Action _configHttpListener; 20 | 21 | /// 22 | /// 通过端口号初始化反向 WebSocket 服务器。 23 | /// 24 | /// 端口号。 25 | /// 端口号不合法。 26 | public ReverseWebSocketServer(int port) 27 | { 28 | if (port is < IPEndPoint.MinPort or > IPEndPoint.MaxPort) 29 | throw new ArgumentOutOfRangeException(nameof(port), "Port 必须是 0-65535 的数。"); 30 | _configHttpListener = httpListener => httpListener.Prefixes.Add($"http://+:{port}/"); 31 | _configHttpListener(_httpListener); 32 | } 33 | 34 | /// 35 | /// 通过前缀初始化反向 WebSocket 服务器。 36 | /// 37 | /// 监听前缀。 38 | /// prefixnull 39 | /// 传入的不是合法的 URI 格式。 40 | /// 传入的前缀不合法。 41 | public ReverseWebSocketServer(string prefix) 42 | { 43 | var uriBuilder = new UriBuilder(prefix); 44 | if ("ws".Equals(uriBuilder.Scheme, StringComparison.OrdinalIgnoreCase)) 45 | { 46 | uriBuilder.Scheme = "http"; 47 | } 48 | else if ("wss".Equals(uriBuilder.Scheme, StringComparison.OrdinalIgnoreCase)) 49 | { 50 | uriBuilder.Scheme = "https"; 51 | } 52 | prefix = uriBuilder.Uri.AbsoluteUri; 53 | if (!prefix.EndsWith('/')) 54 | { 55 | prefix += "/"; 56 | } 57 | _configHttpListener = httpListener => httpListener.Prefixes.Add(prefix); 58 | _configHttpListener(_httpListener); 59 | } 60 | 61 | ///// 62 | ///// 返回默认的 API 客户端。当调用 API 63 | ///// 时,客户端任选一个当前已建立的 WebSocket 连接发送请求。 64 | ///// 65 | //public HttpApiClient DefaultClient { get; } 66 | 67 | ///// 68 | ///// 返回默认的 Listener。此 Listener 会处理来自所有连接的请求。 69 | ///// 70 | //public ApiPostListener DefaultListener { get; } 71 | 72 | private Task RunListeningTask(CancellationToken cancellationToken) 73 | { 74 | // 此方法返回 Task,但是不能标记为 async。 75 | // 如果标记为 async,抛出的异常将被包裹在 Task 中,而不会直接被抛出。 76 | if (!_listeningSemaphore.Wait(0)) 77 | { 78 | throw new InvalidOperationException("反向 WebSocket 服务器已经在监听中。"); 79 | } 80 | return RunInternalAsync(cancellationToken); 81 | 82 | async Task RunInternalAsync(CancellationToken cancellationToken) 83 | { 84 | try 85 | { 86 | if (_httpListener is null) 87 | { 88 | // 为了检查前缀格式,此类的构造函数中会默认初始化一个 HttpListener。 89 | // 如果检测到已初始化,则直接使用。 90 | // 否则,重新初始化 HttpListener。 91 | _httpListener = new HttpListener(); 92 | _configHttpListener(_httpListener); 93 | } 94 | _httpListener.Start(); 95 | cancellationToken.Register(() => _httpListener.Stop()); 96 | while (true) 97 | { 98 | var context = await _httpListener.GetContextAsync().ConfigureAwait(false); 99 | _ = Process(context, cancellationToken); 100 | } 101 | } 102 | catch (Exception e) 103 | { 104 | Console.WriteLine(e); 105 | } 106 | finally 107 | { 108 | var oldListener = Interlocked.Exchange(ref _httpListener, null); 109 | _listeningSemaphore.Release(); 110 | (oldListener as IDisposable)?.Dispose(); 111 | } 112 | } 113 | } 114 | 115 | private async Task Process(HttpListenerContext context, CancellationToken cancellationToken) 116 | { 117 | // Check ws request 118 | if (!context.Request.IsWebSocketRequest) 119 | { 120 | using var response = context.Response; 121 | await RefuseBadRequest(response, "Must use WebSocket connection.").ConfigureAwait(false); 122 | return; 123 | } 124 | 125 | // Check necessary headers 126 | if (context.Request.Headers["X-Client-Role"] != "Universal") 127 | { 128 | using var response = context.Response; 129 | await RefuseBadRequest(response, "Must use Universal connection.").ConfigureAwait(false); 130 | return; 131 | } 132 | if (!long.TryParse(context.Request.Headers["X-Self-ID"], out var selfId)) 133 | { 134 | using var response = context.Response; 135 | await RefuseBadRequest(response, "X-Self-ID header is not found or not valid.").ConfigureAwait(false); 136 | return; 137 | } 138 | 139 | var configAction = await _authentication(context.Request).ConfigureAwait(false); 140 | if (configAction is null) 141 | { 142 | using var response = context.Response; 143 | response.StatusCode = (int)HttpStatusCode.Unauthorized; 144 | return; 145 | } 146 | 147 | var wsContext = await context.AcceptWebSocketAsync(null).ConfigureAwait(false); 148 | var info = new ReverseConnectionInfo(wsContext, context.Request, selfId, _createListener(selfId)); 149 | info.WebSocketManager.Start(cancellationToken); 150 | configAction(info.ApiPostListener, info.SelfId); 151 | info.ApiPostListener.StartProcessEventInternal(); 152 | 153 | // TODO: 把建立的连接存起来 154 | // TODO: 检测连接是否断开。当断开时回收资源,并从连接列表中清除。 155 | } 156 | 157 | private static async Task RefuseBadRequest(HttpListenerResponse response, string responseText) 158 | { 159 | response.StatusCode = (int)HttpStatusCode.BadRequest; 160 | response.ContentType = "text/plain"; 161 | using var writer = new StreamWriter(response.OutputStream); 162 | await writer.WriteLineAsync(responseText).ConfigureAwait(false); 163 | } 164 | 165 | /// 166 | /// 开始监听反向 WebSocket 请求。 167 | /// 168 | /// 取消令牌。 169 | /// 正在监听,不能重复启动。 170 | public void Start(CancellationToken cancellationToken = default) => _ = RunListeningTask(cancellationToken); 171 | 172 | /// 173 | /// 配置 Listener,并按 Access Token 及连接的 bot 174 | /// 账号鉴权。传入的方法将在每次建立连接并鉴权成功时调用。 175 | /// 176 | /// 配置 Listener 的方法。 177 | /// Access Token,如果为 null,则跳过此认证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 178 | /// 连接的 QQ 号,如果为 null,则跳过 QQ 号验证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 179 | /// confignull. 180 | public void SetListenerAuthenticationAndConfiguration(Action config, 181 | string accessToken = null, 182 | long? selfId = null) 183 | => SetListenerAuthenticationAndConfiguration(config, accessToken, selfId); 184 | 185 | /// 186 | /// 用自定义的派生类配置 Listener,并按 Access Token 及连接的 bot 187 | /// 账号鉴权。传入的方法将在每次建立连接并鉴权成功时调用。 188 | /// 189 | /// Listener 的派生类。 190 | /// 配置 Listener 的方法。 191 | /// Access Token,如果为 null,则跳过此认证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 192 | /// 连接的 QQ 号,如果为 null,则跳过 QQ 号验证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 193 | /// confignull. 194 | public void SetListenerAuthenticationAndConfiguration(Action config, 195 | string accessToken = null, 196 | long? selfId = null) 197 | where T : NegativeWebSocketEventListener, new() 198 | { 199 | if (config is null) 200 | { 201 | throw new ArgumentNullException(nameof(config)); 202 | } 203 | 204 | SetListenerAuthenticationAndConfiguration(_ => Task.FromResult(config), accessToken, selfId); 205 | } 206 | 207 | /// 208 | /// 配置认证和配置。 209 | /// 210 | /// 认证方法。此方法应返回配置方法。 211 | /// Access Token,如果为 null,则跳过此认证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 212 | /// 连接的 QQ 号,如果为 null,则跳过 QQ 号验证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 213 | /// authenticationnull. 214 | public void SetListenerAuthenticationAndConfiguration( 215 | Func>> authentication, 216 | string accessToken = null, 217 | long? selfId = null) 218 | => SetListenerAuthenticationAndConfiguration(authentication, accessToken, selfId); 219 | 220 | /// 221 | /// 用派生类配置认证和配置。 222 | /// 223 | /// Listener 的派生类。 224 | /// 认证方法。此方法应返回配置方法。 225 | /// Access Token,如果为 null,则跳过此认证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 226 | /// 连接的 QQ 号,如果为 null,则跳过 QQ 号验证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 227 | /// authenticationnull. 228 | public void SetListenerAuthenticationAndConfiguration( 229 | Func>> authentication, 230 | string accessToken = null, 231 | long? selfId = null) 232 | where T : NegativeWebSocketEventListener, new() 233 | { 234 | if (authentication is null) 235 | { 236 | throw new ArgumentNullException(nameof(authentication)); 237 | } 238 | 239 | _createListener = _ => new T(); 240 | var convertedAuth = typeof(T) == typeof(NegativeWebSocketEventListener) 241 | ? authentication as Func>> 242 | : ConvertAuthentication(authentication); 243 | if (accessToken is null && selfId is null) 244 | { 245 | _authentication = convertedAuth; 246 | } 247 | else 248 | { 249 | var authFunc = CreateAuthenticationFunction(accessToken, selfId); 250 | _authentication = r => authFunc(r) ? convertedAuth(r) : Task.FromResult>(null); 251 | } 252 | } 253 | 254 | /// 255 | /// 把泛型的认证方法转换成非泛型的。在其他代码中保证类型正确。 256 | /// 257 | /// 258 | /// 259 | /// 转换后的认证方法。 260 | private static Func>> ConvertAuthentication( 261 | Func>> authentication) 262 | where T : NegativeWebSocketEventListener, new() 263 | => async r => ConvertConfiguration(await authentication(r)); 264 | 265 | private static Action ConvertConfiguration(Action config) 266 | where T : NegativeWebSocketEventListener, new() 267 | => (l, selfId) => config((T)l, selfId); 268 | 269 | /// 270 | /// 创建根据 Access Token 和连接的 QQ 号鉴权验证的方法。 271 | /// 272 | /// Access Token,如果为 null,则跳过此认证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 273 | /// 连接的 QQ 号,如果为 null,则跳过 QQ 号验证。注意仅验证 QQ 号并不安全,因为请求可能是伪造的。 274 | /// 创建的鉴权方法。 275 | public static Func CreateAuthenticationFunction(string accessToken, long? selfId) 276 | { 277 | return r => 278 | { 279 | if (!string.IsNullOrEmpty(accessToken)) 280 | { 281 | var headValue = r.Headers["Authorization"]; 282 | if (string.IsNullOrWhiteSpace(headValue)) 283 | return false; 284 | int spaceIndex = headValue.IndexOf(' '); 285 | if (!headValue.AsSpan().Slice(spaceIndex + 1).SequenceEqual(accessToken)) 286 | { 287 | return false; 288 | } 289 | } 290 | if (selfId != null) 291 | { 292 | var idString = selfId.ToString(); 293 | var selfIdValue = r.Headers["X-Self-ID"]; 294 | if (selfIdValue != idString) 295 | return false; 296 | } 297 | return true; 298 | }; 299 | } 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/Sisters.WudiLib.WebSocket.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp2.1 5 | latest 6 | 0.2.1 7 | bleatingsheep 8 | bleatingsheep 9 | bleatingsheep 10 | cqhttp coolq-http-api coolq qq qqbot qqrobot 酷Q 11 | https://github.com/int-and-his-friends/Sisters.WudiLib 12 | 酷Q HTTP API .NET 13 | 14 | MIT 15 | 16 | true 17 | snupkg 18 | 19 | 20 | 21 | Sisters.WudiLib.WebSocket.xml 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/WebSocketEventExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Newtonsoft.Json.Linq; 3 | using Sisters.WudiLib.Posts; 4 | 5 | namespace Sisters.WudiLib.WebSocket 6 | { 7 | internal static class WebSocketEventExtensions 8 | { 9 | internal static async Task ProcessWSMessageAsync(this ApiPostListener listener, JObject eventObject) 10 | { 11 | var response = await Task.Run(() => listener.ProcessPost(eventObject)).ConfigureAwait(false); 12 | var apiClient = listener.ApiClient; 13 | if (response is RequestResponse && !(apiClient is null)) 14 | { 15 | JObject data = eventObject; 16 | data.Merge(JObject.FromObject(response)); 17 | switch (response) 18 | { 19 | case FriendRequestResponse friend: 20 | await apiClient.HandleFriendRequestInternalAsync(data).ConfigureAwait(false); 21 | break; 22 | case GroupRequestResponse group: 23 | await apiClient.HandleGroupRequestInternalAsync(data).ConfigureAwait(false); 24 | break; 25 | default: 26 | break; 27 | } 28 | } 29 | } 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/WebSocketManager.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Net.WebSockets; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Newtonsoft.Json; 7 | using Newtonsoft.Json.Linq; 8 | 9 | namespace Sisters.WudiLib.WebSocket 10 | { 11 | internal abstract class WebSocketManager : IRequestSender, IEventReceiver 12 | { 13 | private static readonly JsonLoadSettings s_jsonLoadSeetings = new(); 14 | private readonly SemaphoreSlim _sendSemaphore = new(1, 1); 15 | protected Task _listenTask; 16 | 17 | /// 18 | /// 指示当前是否已启动。若要检查当前是否可用,请使用 属性。 19 | /// 20 | public virtual bool IsRunning => _listenTask?.IsCompleted == false; 21 | 22 | /// 23 | /// 获取当前 WebSocket 是否可用。注意自动重连过程中此项为 24 | /// false,但无法再次通过 25 | /// 连接。此外,在被动 WS 管理器中,此属性仅指示 WS 连接状态,不代表已经开始接收事件。 26 | /// 27 | public virtual bool IsAvailable => WebSocket?.State == WebSocketState.Open; 28 | 29 | public event Action SocketDisconnected; 30 | public Action OnResponse { get; set; } 31 | public Action OnEvent { get; set; } 32 | 33 | internal System.Net.WebSockets.WebSocket WebSocket { get; private protected set; } 34 | 35 | 36 | /// 37 | /// Send message through connected WebSocket. 38 | /// 39 | /// 40 | /// 41 | /// 42 | /// 43 | /// Will be saved when creating new instance of WebSocket. 44 | /// 45 | private async Task SendAsync(ArraySegment buffer, WebSocketMessageType messageType, bool endOfMessage, CancellationToken cancellationToken) 46 | { 47 | await _sendSemaphore.WaitAsync().ConfigureAwait(false); 48 | try 49 | { 50 | var ws = await GetWebSocketAsync(cancellationToken).ConfigureAwait(false); 51 | await ws.SendAsync(buffer, messageType, endOfMessage, cancellationToken).ConfigureAwait(false); 52 | } 53 | finally 54 | { 55 | _sendSemaphore.Release(); 56 | } 57 | } 58 | 59 | /// 60 | /// Send message through connected WebSocket. 61 | /// 62 | /// 63 | /// 64 | /// Will be saved when creating new instance of WebSocket. 65 | /// 66 | public Task SendAsync(ArraySegment buffer, CancellationToken cancellationToken = default) 67 | => SendAsync(buffer, WebSocketMessageType.Text, true, cancellationToken); 68 | 69 | protected void Dispatch(byte[] data) 70 | { 71 | var jObject = JObject.Load(new JsonTextReader(new StreamReader(new MemoryStream(data))), s_jsonLoadSeetings); 72 | var isResponse = jObject.ContainsKey("status") && jObject.ContainsKey("retcode"); 73 | var isEvent = jObject.ContainsKey("post_type"); 74 | if (isResponse == isEvent) 75 | { 76 | // Must be either response or event. 77 | // Ignore. 78 | return; 79 | } 80 | if (isResponse) 81 | { 82 | OnResponse?.Invoke(data, jObject); 83 | } 84 | else 85 | {// Event 86 | OnEvent?.Invoke(data, jObject); 87 | } 88 | } 89 | 90 | protected void OnSocketDisconnected() => SocketDisconnected?.Invoke(); 91 | 92 | protected abstract Task GetWebSocketAsync(CancellationToken cancellationToken); 93 | protected abstract Task RunListeningTask(CancellationToken cancellationToken); 94 | } 95 | } -------------------------------------------------------------------------------- /Sisters.WudiLib.WebSocket/WebSocketUtility.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Net.WebSockets; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | using System.Web; 6 | 7 | namespace Sisters.WudiLib.WebSocket 8 | { 9 | internal static class WebSocketUtility 10 | { 11 | internal static Uri CreateUri(string url, string accessToken) 12 | { 13 | var uriBuilder = new UriBuilder(url); 14 | if (!string.IsNullOrEmpty(accessToken)) 15 | { 16 | var query = HttpUtility.ParseQueryString(uriBuilder.Query); 17 | query["access_token"] = accessToken; 18 | /* 在.NET Framework 中,这里的 ToString 会编码成 %uxxxx 的格式, 19 | * 现在已经不用这种格式了。.NET Core 可以正确处理。解决这个问题之后, 20 | * 此类库应该可以用于 .NET Framework。 21 | */ 22 | uriBuilder.Query = query.ToString(); 23 | } 24 | Uri uri = uriBuilder.Uri; 25 | return uri; 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sisters.WudiLib.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.27130.2026 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sisters.WudiLib", "Sisters.WudiLib\Sisters.WudiLib.csproj", "{5035FEA8-48AD-4FED-87B8-34AA2BEE765B}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sisters.WudiLibTest", "Sisters.WudiLibTest\Sisters.WudiLibTest.csproj", "{6C8D4CF5-15EA-4729-AA61-D0264243E63B}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sisters.WudiLib.Tests", "Sisters.WudiLib.Tests\Sisters.WudiLib.Tests.csproj", "{FCF0066E-4326-4F89-8283-DC6FCE223C1A}" 11 | EndProject 12 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sisters.WudiLib.WebSocket", "Sisters.WudiLib.WebSocket\Sisters.WudiLib.WebSocket.csproj", "{ECF56FB2-EA27-4E27-B866-EC0A430190DA}" 13 | EndProject 14 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sisters.WudiLib.WebSocket.Test", "Sisters.WudiLib.WebSocket.Test\Sisters.WudiLib.WebSocket.Test.csproj", "{5045CE29-004E-451C-86BE-70A8F48DEA1B}" 15 | EndProject 16 | Global 17 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 18 | Debug|Any CPU = Debug|Any CPU 19 | Release|Any CPU = Release|Any CPU 20 | EndGlobalSection 21 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 22 | {5035FEA8-48AD-4FED-87B8-34AA2BEE765B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 23 | {5035FEA8-48AD-4FED-87B8-34AA2BEE765B}.Debug|Any CPU.Build.0 = Debug|Any CPU 24 | {5035FEA8-48AD-4FED-87B8-34AA2BEE765B}.Release|Any CPU.ActiveCfg = Release|Any CPU 25 | {5035FEA8-48AD-4FED-87B8-34AA2BEE765B}.Release|Any CPU.Build.0 = Release|Any CPU 26 | {6C8D4CF5-15EA-4729-AA61-D0264243E63B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 27 | {6C8D4CF5-15EA-4729-AA61-D0264243E63B}.Debug|Any CPU.Build.0 = Debug|Any CPU 28 | {6C8D4CF5-15EA-4729-AA61-D0264243E63B}.Release|Any CPU.ActiveCfg = Release|Any CPU 29 | {6C8D4CF5-15EA-4729-AA61-D0264243E63B}.Release|Any CPU.Build.0 = Release|Any CPU 30 | {FCF0066E-4326-4F89-8283-DC6FCE223C1A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 31 | {FCF0066E-4326-4F89-8283-DC6FCE223C1A}.Debug|Any CPU.Build.0 = Debug|Any CPU 32 | {FCF0066E-4326-4F89-8283-DC6FCE223C1A}.Release|Any CPU.ActiveCfg = Release|Any CPU 33 | {FCF0066E-4326-4F89-8283-DC6FCE223C1A}.Release|Any CPU.Build.0 = Release|Any CPU 34 | {ECF56FB2-EA27-4E27-B866-EC0A430190DA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 35 | {ECF56FB2-EA27-4E27-B866-EC0A430190DA}.Debug|Any CPU.Build.0 = Debug|Any CPU 36 | {ECF56FB2-EA27-4E27-B866-EC0A430190DA}.Release|Any CPU.ActiveCfg = Release|Any CPU 37 | {ECF56FB2-EA27-4E27-B866-EC0A430190DA}.Release|Any CPU.Build.0 = Release|Any CPU 38 | {5045CE29-004E-451C-86BE-70A8F48DEA1B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 39 | {5045CE29-004E-451C-86BE-70A8F48DEA1B}.Debug|Any CPU.Build.0 = Debug|Any CPU 40 | {5045CE29-004E-451C-86BE-70A8F48DEA1B}.Release|Any CPU.ActiveCfg = Release|Any CPU 41 | {5045CE29-004E-451C-86BE-70A8F48DEA1B}.Release|Any CPU.Build.0 = Release|Any CPU 42 | EndGlobalSection 43 | GlobalSection(SolutionProperties) = preSolution 44 | HideSolutionNode = FALSE 45 | EndGlobalSection 46 | GlobalSection(ExtensibilityGlobals) = postSolution 47 | SolutionGuid = {0E75AEEA-90D7-4A20-9624-4909BDA69A1E} 48 | EndGlobalSection 49 | EndGlobal 50 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Builders/Annotations/PostAttribute.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Sisters.WudiLib.Builders.Annotations 4 | { 5 | #nullable enable 6 | [AttributeUsage(AttributeTargets.Class, Inherited = false, AllowMultiple = true)] 7 | internal sealed class PostAttribute : Attribute 8 | { 9 | // This is a positional argument 10 | public PostAttribute(string field, string value) 11 | { 12 | Field = field; 13 | Value = value; 14 | } 15 | 16 | public string Field { get; } 17 | public string Value { get; } 18 | } 19 | #nullable restore 20 | } 21 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Builders/DispatcherBuilder.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reflection; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | using Newtonsoft.Json.Linq; 7 | using Sisters.WudiLib.Builders.Annotations; 8 | using Sisters.WudiLib.Posts; 9 | 10 | namespace Sisters.WudiLib.Builders 11 | { 12 | #nullable enable 13 | /// 14 | /// 读取事件列表,构造 Dispatcher。 15 | /// 16 | internal class DispatcherBuilder 17 | { 18 | private readonly ILogger _logger; 19 | private readonly PostTreeNode _root = new PostTreeNode(typeof(Post), false); 20 | 21 | /// 22 | /// 初始化一个 Dispatcher Builder。 23 | /// 24 | /// Logger。 25 | public DispatcherBuilder(ILogger? logger = null) 26 | { 27 | _logger = logger ?? NullLogger.Instance; 28 | } 29 | 30 | /// 31 | /// 从程序集中搜索事件列表并添加。 32 | /// 33 | /// 34 | /// This. 35 | public DispatcherBuilder AddAssembly(Assembly assembly) => AddAssemblyExcept(assembly); 36 | 37 | public DispatcherBuilder AddAssemblyExcept(Assembly assembly, params Type[] excludedTypes) 38 | { 39 | _logger.LogInformation($"添加程序集:{assembly.FullName},跳过 {excludedTypes.Length} 个类型。"); 40 | foreach (var t in assembly.GetTypes().Except(excludedTypes)) 41 | { 42 | if (t.GetCustomAttributes().Any()) 43 | { 44 | if (!t.IsAbstract) 45 | { 46 | _logger.LogInformation($"发现 {t.FullName},正在添加。"); 47 | _root.AddType(t); 48 | } 49 | else 50 | { 51 | _logger.LogInformation($"跳过抽象类 {t.FullName}。"); 52 | } 53 | } 54 | } 55 | return this; 56 | } 57 | 58 | /// 59 | /// 添加事件类型。 60 | /// 61 | /// 要添加的类型。 62 | /// This. 63 | public DispatcherBuilder AddType() => AddType(typeof(T)); 64 | 65 | public DispatcherBuilder AddType(Type type) 66 | { 67 | _logger.LogInformation($"添加类型 {type.FullName}"); 68 | _root.AddType(type); 69 | return this; 70 | } 71 | 72 | /// 73 | /// 构建调配委托。 74 | /// 75 | /// 76 | public Func BuildDispatchingDelegate() 77 | { 78 | throw new NotImplementedException(); 79 | } 80 | 81 | /// 82 | /// 构建 Dispatcher。 83 | /// 84 | /// 85 | /// 86 | public Dispatcher BuildDispatcher(ILoggerFactory loggerFactory) 87 | { 88 | var logger = loggerFactory.CreateLogger(); 89 | throw new NotImplementedException(); 90 | } 91 | } 92 | #nullable restore 93 | } 94 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Builders/PostTreeNode.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Linq; 5 | using System.Reflection; 6 | using Sisters.WudiLib.Builders.Annotations; 7 | using Sisters.WudiLib.Posts; 8 | 9 | namespace Sisters.WudiLib.Builders 10 | { 11 | #nullable enable 12 | internal class PostTreeNode 13 | { 14 | private readonly Type _type; 15 | private readonly ISet _definedKeys; 16 | private readonly IReadOnlyDictionary _fields; 17 | private readonly LinkedList _children = new(); 18 | private bool _isSpecified; 19 | 20 | /// 21 | /// 从上报数据类型构造 。 22 | /// 23 | /// 上报数据类型。 24 | /// 是否要初始化此类型。 25 | /// 构造时出现异常。 26 | /// type 不是 的子类。 27 | public PostTreeNode(Type type, bool isSpecified = true) 28 | { 29 | if (!typeof(Post).IsAssignableFrom(type)) 30 | { 31 | throw new ArgumentException($"传入的类型必须是 {typeof(Post).FullName} 的子类。", nameof(type)); 32 | } 33 | if (isSpecified && type.IsAbstract) 34 | { 35 | throw new ArgumentException($"无法接受抽象类型 {type.FullName}。", nameof(type)); 36 | } 37 | _type = type; 38 | var attributes = type.GetCustomAttributes(false); 39 | var definedKeys = attributes.Select(a => a.Field); 40 | _definedKeys = new HashSet(); 41 | foreach (var fieldName in definedKeys) 42 | { 43 | if (!_definedKeys.Add(fieldName)) 44 | { 45 | throw new WudiLibBuilderException($"在上报类型 {type.FullName} 中重复出现了字段 {fieldName} 的约束。"); 46 | } 47 | } 48 | _fields = attributes.ToDictionary(a => a.Field, a => a.Value); 49 | _isSpecified = isSpecified; 50 | } 51 | 52 | /// 53 | /// 把相应的 PostTreeNode 直接添加到此 PostTreeNode 下面。 54 | /// 55 | /// 56 | private void AddNodeToThis(PostTreeNode node) 57 | { 58 | Debug.Assert(node._type.BaseType == _type); 59 | 60 | // 在链表 _children 中,指定了更多字段(更具体)的上报类型应该排在更前面。 61 | for (var llNode = _children.First; llNode != null; llNode = llNode.Next) 62 | { 63 | var existingNoMoreGeneric = IsNoMoreGeneric(llNode.Value._definedKeys, node._definedKeys); 64 | if (existingNoMoreGeneric == true) 65 | { 66 | // 当前遍历到的更具体(或同样具体),因此检查是否定义了完全一样的字段。 67 | if (node._definedKeys.SetEquals(llNode.Value._definedKeys)) 68 | { 69 | bool identical = true; 70 | foreach (var kvp in node._fields) 71 | { 72 | if (llNode.Value._fields[kvp.Key] != kvp.Value) 73 | { 74 | identical = false; 75 | break; 76 | } 77 | } 78 | if (identical) 79 | throw new WudiLibBuilderException($"Types {node._type.FullName} and {llNode.Value._type.FullName} have identical field constraint."); 80 | } 81 | continue; 82 | } 83 | if (existingNoMoreGeneric == null) 84 | throw new WudiLibBuilderException($"Types {node._type.FullName} and {llNode.Value._type.FullName} have conflict."); 85 | 86 | _children.AddBefore(llNode, node); 87 | return; 88 | } 89 | _children.AddLast(node); 90 | } 91 | 92 | public void AddType(Type type) 93 | { 94 | if (!type.IsSubclassOf(_type)) 95 | { 96 | throw new ArgumentException("必须添加指示的子类。", nameof(type)); 97 | } 98 | if (type == _type) 99 | { 100 | _isSpecified = _isSpecified 101 | ? throw new InvalidOperationException("此类型已经添加,不能再次添加。") 102 | : true; 103 | } 104 | 105 | // 获取从当前类型到要添加类型的继承链。 106 | var chain = new Stack(); 107 | for (Type t = type; t != _type; t = t.BaseType) 108 | { 109 | chain.Push(t); 110 | } 111 | 112 | // 添加缺失类型并获取代表要添加的类型的 PostTreeNode。 113 | var deepest = this; 114 | while (chain.Count > 0) 115 | { 116 | var current = chain.Pop(); 117 | var node = deepest._children.FirstOrDefault(n => n._type == current); 118 | if (node == null) 119 | { 120 | node = new PostTreeNode(current, false); 121 | deepest.AddNodeToThis(node); 122 | } 123 | 124 | deepest = node; 125 | } 126 | 127 | // 最深的节点就是要添加的类型对应的 PostTreeNode。 128 | deepest.AddType(type); 129 | } 130 | 131 | /// 132 | /// 传入两组字段名,判断 compared 是否不如 comparing 133 | /// 范围广。如果包含的字段少,则范围更广。 134 | /// 135 | /// 被比较的。 136 | /// 比较基准。 137 | /// 如果前者范围更广或者相同,则为 true;如果后者范围更广,则为 138 | /// false;否则为 null 139 | public static bool? IsNoMoreGeneric( 140 | ISet compared, 141 | ISet comparing) 142 | { 143 | if (comparing.IsSubsetOf(compared)) 144 | return true; 145 | if (comparing.IsSupersetOf(compared)) 146 | return false; 147 | return null; 148 | } 149 | } 150 | #nullable restore 151 | } 152 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Builders/WudiLibBuilderException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Sisters.WudiLib.Builders 4 | { 5 | #pragma warning disable CS1591 // Missing XML comment for publicly visible type or member 6 | [Serializable] 7 | public class WudiLibBuilderException : Exception 8 | { 9 | public WudiLibBuilderException() { } 10 | public WudiLibBuilderException(string message) : base(message) { } 11 | public WudiLibBuilderException(string message, Exception inner) : base(message, inner) { } 12 | protected WudiLibBuilderException( 13 | System.Runtime.Serialization.SerializationInfo info, 14 | System.Runtime.Serialization.StreamingContext context) : base(info, context) { } 15 | } 16 | #pragma warning restore CS1591 // Missing XML comment for publicly visible type or member 17 | } 18 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Dispatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Logging.Abstractions; 6 | using Sisters.WudiLib.Posts; 7 | 8 | namespace Sisters.WudiLib 9 | { 10 | #nullable enable 11 | /// 12 | /// Dispatches posts. 13 | /// 14 | internal class Dispatcher 15 | { 16 | private readonly ILogger _logger; 17 | private readonly HttpApiClient _onebotApi; 18 | private readonly ApiPostListener _onebotPost; 19 | /// 20 | /// 初始化一个 Dispatcher。 21 | /// 22 | /// 23 | /// 24 | /// Logger。 25 | public Dispatcher(HttpApiClient onebotApi, ApiPostListener onebotPost, ILogger? logger = null) 26 | { 27 | _logger = logger ?? NullLogger.Instance; 28 | _onebotApi = onebotApi; 29 | _onebotPost = onebotPost; 30 | } 31 | 32 | 33 | } 34 | #nullable restore 35 | } 36 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Exceptions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Sisters.WudiLib 6 | { 7 | /// 8 | /// 访问 API 时出现的异常,例如,通过网络访问 API 失败。 9 | /// 10 | public class ApiAccessException : Exception 11 | { 12 | /// 13 | public ApiAccessException() 14 | { 15 | 16 | } 17 | 18 | /// 19 | /// Initializes a new instance of the class with a specified error message. 20 | /// 21 | /// The message that describes the error. 22 | public ApiAccessException(string message) 23 | : base(message) 24 | { 25 | 26 | } 27 | 28 | /// 29 | public ApiAccessException(string message, Exception innerException) 30 | : base(message, innerException) 31 | { 32 | 33 | } 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Message.cs: -------------------------------------------------------------------------------- 1 | namespace Sisters.WudiLib 2 | { 3 | /// 4 | /// 各种消息类型的基类。 5 | /// 6 | public abstract class Message 7 | { 8 | /// 9 | /// 构造 实例必须运行的方法。 10 | /// 11 | protected Message() 12 | { 13 | } 14 | 15 | /// 16 | /// 返回发送时要序列化的对象。 17 | /// 18 | protected internal abstract object Serializing { get; } 19 | 20 | /// 21 | /// 用字符串表示的原始消息。 22 | /// 23 | public abstract string Raw { get; } 24 | } 25 | } -------------------------------------------------------------------------------- /Sisters.WudiLib/MessageEscapingExtensions.cs: -------------------------------------------------------------------------------- 1 | namespace Sisters.WudiLib 2 | { 3 | internal static class MessageEscapingExtensions 4 | { 5 | 6 | /// 7 | /// 转义(编码)。 8 | /// 9 | /// 要编码的字符串。 10 | /// 是否是CQ码。如果为 true,也会转义逗号(,);否则不会转义逗号。 11 | /// 转义结果。 12 | internal static string BeforeSend(this string before, bool isCqCodeArg) 13 | { 14 | var result = before 15 | .Replace("&", "&") 16 | .Replace("[", "[") 17 | .Replace("]", "]"); 18 | if (isCqCodeArg) 19 | result = result.Replace(",", ","); 20 | return result; 21 | } 22 | 23 | /// 24 | /// 反转义(解码)。 25 | /// 26 | /// 要解码的字符串。 27 | /// 解码结果。 28 | internal static string AfterReceive(this string received) 29 | => received 30 | .Replace(",", ",") 31 | .Replace("[", "[") 32 | .Replace("]", "]") 33 | .Replace("&", "&"); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sisters.WudiLib/MessageInterpolatedStringHandler.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Runtime.CompilerServices; 4 | 5 | namespace Sisters.WudiLib 6 | { 7 | #if NET6_0_OR_GREATER 8 | #nullable enable 9 | [InterpolatedStringHandler] 10 | public ref struct MessageInterpolatedStringHandler 11 | { 12 | private readonly List
_builder; 13 | 14 | public MessageInterpolatedStringHandler(int literalLength, int formattedCount) 15 | { 16 | _builder = new List
(literalLength); 17 | } 18 | 19 | public void AppendLiteral(string s) 20 | { 21 | _builder.Add(Section.Text(s)); 22 | } 23 | 24 | public void AppendFormatted(T t) 25 | { 26 | if (t is Section section) 27 | { 28 | _builder.Add(section); 29 | } 30 | else if (t is SendingMessage sendingMessage) 31 | { 32 | _builder.AddRange(sendingMessage.Sections); 33 | } 34 | else if (t is RawMessage) 35 | { 36 | throw new ArgumentException("用内联字符串构建消息实例时不支持包含 RawMessage。", nameof(t)); 37 | } 38 | else 39 | { 40 | _builder.Add(Section.Text(t?.ToString())); 41 | } 42 | } 43 | 44 | public void AppendFormatted(T t, string format) where T : IFormattable 45 | { 46 | _builder.Add(Section.Text(t?.ToString(format, null))); 47 | } 48 | 49 | internal SendingMessage GetFormattedMessage() => new(_builder); 50 | } 51 | #nullable restore 52 | #endif 53 | } 54 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/AnonymousInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Globalization; 2 | using Newtonsoft.Json; 3 | 4 | namespace Sisters.WudiLib.Posts 5 | { 6 | /// 7 | /// 匿名用户信息。 8 | /// 9 | public sealed class AnonymousInfo 10 | { 11 | /// 匿名用户 flag,在调用禁言 API 时需要传入。 12 | [JsonProperty("flag")] 13 | public string Flag { get; private set; } 14 | /// 匿名用户 ID。 15 | [JsonProperty("id")] 16 | public int Id { get; private set; } 17 | /// 匿名用户名称。 18 | [JsonProperty("name")] 19 | public string Name { get; private set; } 20 | 21 | public override string ToString() => Name ?? Flag ?? Id.ToString(CultureInfo.InvariantCulture); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/ApiPostListener.GetPost.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Linq; 4 | 5 | namespace Sisters.WudiLib.Posts 6 | { 7 | partial class ApiPostListener 8 | { 9 | //事件上报? 10 | internal static Post GetPost(string json) 11 | { 12 | // TODO 13 | if (string.IsNullOrEmpty(json)) 14 | return null; 15 | 16 | JObject contentObject = JsonConvert.DeserializeObject(json); 17 | Post result = null; 18 | switch (contentObject[Post.TypeField].ToObject()) 19 | { 20 | case Post.Message: 21 | result = GetMessagePost(contentObject); 22 | break; 23 | case Post.Notice: 24 | result = GetNoticePost(contentObject); 25 | break; 26 | case Post.Request: 27 | result = GetRequestPost(contentObject); 28 | break; 29 | case Post.MetaEvent: 30 | // TODO 31 | break; 32 | } 33 | 34 | return result; 35 | } 36 | 37 | private static Message GetMessagePost(JObject jObject) 38 | { 39 | Message result = null; 40 | switch (jObject[Message.TypeField].ToObject()) 41 | { 42 | case Message.PrivateType: 43 | result = jObject.ToObject(); 44 | break; 45 | case Message.GroupType: 46 | result = jObject.ToObject(); 47 | break; 48 | case Message.DiscussType: 49 | result = jObject.ToObject(); 50 | break; 51 | default: 52 | throw new Exception("消息事件TypeField错误"); 53 | } 54 | 55 | return result; 56 | } 57 | 58 | private static Notice GetNoticePost(JObject jObject) 59 | { 60 | Notice result = null; 61 | switch (jObject[Notice.TypeField].ToObject()) 62 | { 63 | case Notice.FriendAdd: 64 | result = jObject.ToObject(); 65 | break; 66 | case Notice.GroupAdmin: 67 | result = jObject.ToObject(); 68 | break; 69 | case Notice.GroupDecrease: 70 | result = jObject.ToObject(); 71 | break; 72 | case Notice.GroupIncrease: 73 | result = jObject.ToObject(); 74 | break; 75 | case Notice.GroupUpload: 76 | result = jObject.ToObject(); 77 | break; 78 | default: 79 | throw new Exception("通知事件TypeField错误"); 80 | } 81 | 82 | return result; 83 | } 84 | 85 | private static Request GetRequestPost(JObject jObject) 86 | { 87 | Request result; 88 | switch (jObject[Request.TypeField].ToObject()) 89 | { 90 | case Request.Friend: 91 | result = jObject.ToObject(); 92 | break; 93 | case Request.Group: 94 | result = jObject.ToObject(); 95 | break; 96 | default: 97 | throw new Exception("请求事件TypeField错误"); 98 | } 99 | 100 | return result; 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/Endpoint.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | 5 | namespace Sisters.WudiLib.Posts 6 | { 7 | /// 8 | /// 表示要将消息发送至的地点的类。可以通过 实例唯一确定要将消息发送至哪里。 9 | /// 10 | [JsonObject(MemberSerialization.OptIn)] 11 | public abstract class Endpoint : IEquatable//, IComparable 12 | { 13 | internal Endpoint() 14 | { 15 | // ignored 16 | } 17 | 18 | [JsonProperty("message_type")] 19 | internal string MessageType 20 | { 21 | get 22 | { 23 | const string suffix = nameof(Endpoint); 24 | string type = this.GetType().Name; 25 | if (type.EndsWith(suffix, StringComparison.Ordinal)) 26 | type = type.Substring(0, type.Length - suffix.Length); 27 | return type.ToLowerInvariant(); 28 | } 29 | } 30 | 31 | private protected abstract long EndpointId { get; } 32 | 33 | internal static Endpoint FromMessage(Message message) 34 | { 35 | switch (message) 36 | { 37 | case PrivateMessage p: 38 | return new PrivateEndpoint(p.UserId); 39 | case GroupMessage g: 40 | return new GroupEndpoint(g.GroupId); 41 | case DiscussMessage d: 42 | return new DiscussEndpoint(d.DiscussId); 43 | default: 44 | break; 45 | } 46 | 47 | return null; 48 | } 49 | 50 | /// 51 | public abstract override bool Equals(object obj); 52 | /// 53 | public bool Equals(Endpoint other) => this.Equals(other as object); 54 | 55 | /// 56 | public abstract override int GetHashCode(); 57 | 58 | /// 59 | /// 获取形如 {EndpointType}/{Id} 的字符串。 60 | /// 61 | /// 形如 {EndpointType}/{Id} 的字符串。 62 | public override string ToString() => $"{MessageType}/{EndpointId}"; 63 | 64 | /// 65 | public static bool operator ==(Endpoint endpoint1, Endpoint endpoint2) => EqualityComparer.Default.Equals(endpoint1, endpoint2); 66 | /// 67 | public static bool operator !=(Endpoint endpoint1, Endpoint endpoint2) => !(endpoint1 == endpoint2); 68 | } 69 | 70 | /// 71 | public sealed class PrivateEndpoint : Endpoint, IEquatable 72 | { 73 | /// 74 | public PrivateEndpoint(long userId) => this.UserId = userId; 75 | 76 | /// 77 | /// 用户 QQ 号。 78 | /// 79 | [JsonProperty("user_id")] 80 | public long UserId { get; } 81 | private protected override long EndpointId => UserId; 82 | 83 | /// 84 | public override bool Equals(object obj) => Equals(obj as PrivateEndpoint); 85 | /// 86 | public bool Equals(PrivateEndpoint other) => other != null && UserId == other.UserId; 87 | 88 | /// 89 | public override int GetHashCode() 90 | { 91 | var hashCode = 1708038101; 92 | hashCode = hashCode * -1521134295 + UserId.GetHashCode(); 93 | return hashCode; 94 | } 95 | 96 | /// 97 | public static bool operator ==(PrivateEndpoint endpoint1, PrivateEndpoint endpoint2) => EqualityComparer.Default.Equals(endpoint1, endpoint2); 98 | /// 99 | public static bool operator !=(PrivateEndpoint endpoint1, PrivateEndpoint endpoint2) => !(endpoint1 == endpoint2); 100 | } 101 | 102 | /// 103 | public sealed class GroupEndpoint : Endpoint, IEquatable 104 | { 105 | /// 106 | public GroupEndpoint(long groupId) => this.GroupId = groupId; 107 | 108 | /// 109 | /// 群号。 110 | /// 111 | [JsonProperty("group_id")] 112 | public long GroupId { get; } 113 | private protected override long EndpointId => GroupId; 114 | 115 | /// 116 | public override bool Equals(object obj) => Equals(obj as GroupEndpoint); 117 | /// 118 | public bool Equals(GroupEndpoint other) => other != null && GroupId == other.GroupId; 119 | 120 | /// 121 | public override int GetHashCode() 122 | { 123 | var hashCode = -1449488233; 124 | hashCode = hashCode * -1521134295 + GroupId.GetHashCode(); 125 | return hashCode; 126 | } 127 | 128 | /// 129 | public static bool operator ==(GroupEndpoint endpoint1, GroupEndpoint endpoint2) => EqualityComparer.Default.Equals(endpoint1, endpoint2); 130 | /// 131 | public static bool operator !=(GroupEndpoint endpoint1, GroupEndpoint endpoint2) => !(endpoint1 == endpoint2); 132 | } 133 | 134 | /// 135 | public sealed class DiscussEndpoint : Endpoint, IEquatable 136 | { 137 | /// 138 | public DiscussEndpoint(long discussId) => this.DiscussId = discussId; 139 | 140 | /// 141 | /// 讨论组 ID。 142 | /// 143 | [JsonProperty("discuss_id")] 144 | public long DiscussId { get; } 145 | private protected override long EndpointId => DiscussId; 146 | 147 | /// 148 | public override bool Equals(object obj) => Equals(obj as DiscussEndpoint); 149 | /// 150 | public bool Equals(DiscussEndpoint other) => other != null && DiscussId == other.DiscussId; 151 | 152 | /// 153 | public override int GetHashCode() 154 | { 155 | var hashCode = -54904678; 156 | hashCode = hashCode * -1521134295 + DiscussId.GetHashCode(); 157 | return hashCode; 158 | } 159 | 160 | /// 161 | public static bool operator ==(DiscussEndpoint endpoint1, DiscussEndpoint endpoint2) => EqualityComparer.Default.Equals(endpoint1, endpoint2); 162 | /// 163 | public static bool operator !=(DiscussEndpoint endpoint1, DiscussEndpoint endpoint2) => !(endpoint1 == endpoint2); 164 | } 165 | } 166 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/EventHandler.cs: -------------------------------------------------------------------------------- 1 | namespace Sisters.WudiLib.Posts 2 | { 3 | public delegate GroupRequestResponse GroupRequestEventHandler(HttpApiClient api, GroupRequest request); 4 | 5 | public delegate FriendRequestResponse FriendRequestEventHandler(HttpApiClient api, FriendRequest request); 6 | 7 | public delegate void MessageEventHandler(HttpApiClient api, Message message); 8 | 9 | public delegate void AnonymousMessageEventHanlder(HttpApiClient api, AnonymousMessage message); 10 | 11 | public delegate void GroupNoticeEventHandler(HttpApiClient api, GroupMessage notice); 12 | } 13 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/GroupBanType.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.Serialization; 2 | 3 | namespace Sisters.WudiLib.Posts 4 | { 5 | /// 6 | /// 表示禁言类型(禁言或解除禁言)。 7 | /// 8 | public enum GroupBanType 9 | { 10 | /// 11 | /// 禁言。 12 | /// 13 | [EnumMember(Value = "ban")] 14 | Ban, 15 | 16 | /// 17 | /// 解除禁言。 18 | /// 19 | [EnumMember(Value = "lift_ban")] 20 | LiftBan, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/MessageSource.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | 4 | namespace Sisters.WudiLib.Posts 5 | { 6 | /// 7 | /// 表示消息发送人。可能是普通来源或匿名来源。 8 | /// 9 | public sealed class MessageSource : IEquatable 10 | { 11 | internal MessageSource(long userId, string anonymousFlag = null, string anonymous = null, 12 | bool isAnonymous = false) 13 | { 14 | IsAnonymous = isAnonymous; 15 | if (IsAnonymous) 16 | { 17 | Anonymous = anonymous; 18 | AnonymousFlag = anonymousFlag; 19 | } 20 | else 21 | { 22 | UserId = userId; 23 | } 24 | } 25 | 26 | /// 27 | /// 消息发送者是否匿名。 28 | /// 29 | public bool IsAnonymous { get; } 30 | 31 | /// 匿名用户名称。 32 | public string Anonymous { get; } 33 | 34 | /// 匿名用户 flag,在调用禁言 API 时需要传入。 35 | public string AnonymousFlag { get; } 36 | 37 | /// 38 | /// 发送者 QQ 号。 39 | /// 40 | public long UserId { get; } 41 | 42 | public override string ToString() => IsAnonymous ? Anonymous : UserId.ToString(); 43 | 44 | /// 45 | public override bool Equals(object obj) => this.Equals(obj as MessageSource); 46 | 47 | /// 48 | public bool Equals(MessageSource other) => other != null && this.IsAnonymous == other.IsAnonymous && 49 | this.Anonymous == other.Anonymous && 50 | this.AnonymousFlag == other.AnonymousFlag && 51 | this.UserId == other.UserId; 52 | 53 | /// 54 | public override int GetHashCode() 55 | { 56 | var hashCode = -26995021; 57 | hashCode = hashCode * -1521134295 + this.IsAnonymous.GetHashCode(); 58 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(this.Anonymous); 59 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(this.AnonymousFlag); 60 | hashCode = hashCode * -1521134295 + this.UserId.GetHashCode(); 61 | return hashCode; 62 | } 63 | 64 | /// 65 | public static bool operator ==(MessageSource source1, MessageSource source2) => 66 | EqualityComparer.Default.Equals(source1, source2); 67 | 68 | /// 69 | public static bool operator !=(MessageSource source1, MessageSource source2) => !(source1 == source2); 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/Post.Message.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Sisters.WudiLib.Posts 5 | { 6 | [JsonObject(MemberSerialization.OptIn)] 7 | public abstract class Message : Post 8 | { 9 | /// 10 | /// 表示此消息为私聊消息。 11 | /// 12 | public const string PrivateType = "private"; 13 | /// 14 | /// 表示此消息为群聊消息。 15 | /// 16 | public const string GroupType = "group"; 17 | /// 18 | /// 表示此消息为讨论组消息。 19 | /// 20 | public const string DiscussType = "discuss"; 21 | 22 | internal new const string TypeField = "message_type"; 23 | 24 | public Message() 25 | => _messageLazy = new Lazy(() => new ReceivedMessage(ObjMessage)); 26 | 27 | /// 28 | /// 消息类型(群、私聊、讨论组)。不建议使用本属性判断类型,请使用 is 运算符进行判断。
29 | /// 如:msg is g。 30 | ///
31 | [JsonProperty(TypeField)] 32 | public string MessageType { get; private set; } 33 | [JsonProperty("message_id")] 34 | public int MessageId { get; private set; } 35 | [JsonProperty("message")] 36 | private object ObjMessage { get; set; } 37 | [JsonIgnore] 38 | private readonly Lazy _messageLazy; 39 | [JsonIgnore] 40 | public ReceivedMessage Content => _messageLazy.Value; 41 | [JsonProperty("raw_message")] 42 | public string RawMessage { get; private set; } 43 | [JsonProperty("font")] 44 | public int Font { get; private set; } 45 | 46 | public abstract override Endpoint Endpoint { get; } 47 | public virtual MessageSource Source => new MessageSource(UserId); 48 | } 49 | 50 | [JsonObject(MemberSerialization.OptIn)] 51 | public class GroupMessage : Message 52 | { 53 | internal const string NormalType = "normal"; 54 | internal const string AnonymousType = "anonymous"; 55 | internal const string NoticeType = "notice"; 56 | 57 | [JsonProperty(SubTypeField)] 58 | internal string SubType { get; private set; } 59 | [JsonProperty("group_id")] 60 | public long GroupId { get; private set; } 61 | /// 62 | /// 发送人信息。需要 CoolQ HTTP API 插件版本 >= 4.5.0,部分字段需要 >= 4.7.0。 63 | /// 64 | [JsonProperty("sender")] 65 | public SenderInfo Sender { get; private set; } 66 | 67 | public override Endpoint Endpoint => new GroupEndpoint(GroupId); 68 | } 69 | 70 | [JsonObject(MemberSerialization.OptIn)] 71 | public class PrivateMessage : Message 72 | { 73 | public const string FriendType = "friend"; 74 | public new const string GroupType = "group"; 75 | public new const string DiscussType = "discuss"; 76 | public const string OtherType = "other"; 77 | 78 | [JsonProperty(SubTypeField)] 79 | public string SubType { get; private set; } 80 | 81 | public override Endpoint Endpoint => new PrivateEndpoint(UserId); 82 | } 83 | 84 | [JsonObject(MemberSerialization.OptIn)] 85 | public class AnonymousMessage : GroupMessage 86 | { 87 | [JsonProperty("anonymous")] 88 | public AnonymousInfo Anonymous { get; private set; } 89 | 90 | public override MessageSource Source => new MessageSource(UserId, Anonymous.Flag, Anonymous.Name, true); 91 | } 92 | 93 | [JsonObject(MemberSerialization.OptIn)] 94 | public class DiscussMessage : Message 95 | { 96 | [JsonProperty("discuss_id")] 97 | internal long DiscussId { get; private set; } 98 | 99 | public override Endpoint Endpoint => new DiscussEndpoint(DiscussId); 100 | } 101 | } 102 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/Post.Notice.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | 3 | namespace Sisters.WudiLib.Posts 4 | { 5 | public abstract class Notice : Post 6 | { 7 | internal const string GroupUpload = "group_upload"; 8 | internal const string GroupAdmin = "group_admin"; 9 | internal const string GroupDecrease = "group_decrease"; 10 | internal const string GroupIncrease = "group_increase"; 11 | internal const string GroupBan = "group_ban"; 12 | internal const string FriendAdd = "friend_add"; 13 | 14 | internal new const string TypeField = "notice_type"; 15 | 16 | public abstract override Endpoint Endpoint { get; } 17 | [JsonProperty(TypeField)] 18 | internal string NoticeType { get; private set; } 19 | } 20 | 21 | public sealed class FriendAddNotice : Notice 22 | { 23 | public override Endpoint Endpoint => new PrivateEndpoint(UserId); 24 | } 25 | 26 | public abstract class GroupNotice : Notice 27 | { 28 | [JsonProperty("group_id")] 29 | public long GroupId { get; private set; } 30 | 31 | public override Endpoint Endpoint => new GroupEndpoint(GroupId); 32 | } 33 | 34 | /// 35 | /// 群组禁言事件。 36 | /// 37 | public sealed class GroupBanNotice : GroupNotice 38 | { 39 | /// 40 | /// 禁言类型(禁言或解除禁言)。 41 | /// 42 | [JsonProperty("sub_type")] 43 | public GroupBanType Type { get; private set; } 44 | 45 | [JsonProperty("operator_id")] 46 | public long OperatorId { get; private set; } 47 | 48 | /// 49 | /// 禁言时长(秒)。 50 | /// 51 | [JsonProperty("duration")] 52 | public int Duration { get; private set; } 53 | } 54 | 55 | public sealed class GroupFileNotice : GroupNotice 56 | { 57 | [JsonProperty("file")] 58 | public GroupFile File { get; private set; } 59 | } 60 | 61 | [JsonObject(MemberSerialization.OptIn)] 62 | public sealed class GroupFile 63 | { 64 | [JsonProperty("id")] 65 | public string Id { get; private set; } 66 | 67 | [JsonProperty("name")] 68 | public string Name { get; private set; } 69 | 70 | [JsonProperty("size")] 71 | public long Length { get; private set; } 72 | 73 | [JsonProperty("busid")] 74 | public int BusId { get; private set; } 75 | } 76 | 77 | public sealed class GroupAdminNotice : GroupNotice 78 | { 79 | public const string SetAdmin = "set"; 80 | public const string UnsetAdmin = "unset"; 81 | 82 | [JsonProperty(SubTypeField)] 83 | public string SubType { get; private set; } 84 | } 85 | 86 | public abstract class GroupMemberChangeNotice : GroupNotice 87 | { 88 | [JsonProperty(SubTypeField)] 89 | public string SubType { get; private set; } 90 | 91 | [JsonProperty("operator_id")] 92 | public long OperatorId { get; private set; } 93 | } 94 | 95 | public sealed class GroupMemberIncreaseNotice : GroupMemberChangeNotice 96 | { 97 | /// 98 | /// 表示管理员已同意入群。 99 | /// 100 | public const string AdminApprove = "approve"; 101 | 102 | /// 103 | /// 表示管理员邀请入群。 104 | /// 105 | public const string AdminInvite = "invite"; 106 | 107 | internal bool IsMe => UserId == SelfId; 108 | } 109 | 110 | public sealed class GroupMemberDecreaseNotice : GroupMemberChangeNotice 111 | { 112 | /// 113 | /// 表示主动退群。 114 | /// 115 | public const string Leave = "leave"; 116 | 117 | /// 118 | /// 成员被踢。 119 | /// 120 | public const string Kick = "kick"; 121 | } 122 | 123 | public sealed class KickedNotice : GroupMemberChangeNotice 124 | { 125 | internal const string Kicked = "kick_me"; 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/Post.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Newtonsoft.Json; 4 | using Newtonsoft.Json.Converters; 5 | using Newtonsoft.Json.Linq; 6 | 7 | namespace Sisters.WudiLib.Posts 8 | { 9 | [JsonObject(MemberSerialization.OptIn)] 10 | public abstract class Post 11 | { 12 | internal const string Message = "message"; 13 | internal const string Notice = "notice"; 14 | internal const string Request = "request"; 15 | internal const string MetaEvent = "meta_event"; 16 | 17 | internal const string TypeField = "post_type"; 18 | internal const string SubTypeField = "sub_type"; 19 | 20 | internal Post() 21 | { 22 | // ignored 23 | } 24 | 25 | [JsonProperty(TypeField)] 26 | internal string PostType { get; private set; } 27 | 28 | [JsonProperty("time")] 29 | [JsonConverter(typeof(UnixDateTimeConverter))] 30 | public DateTimeOffset Time { get; private set; } 31 | 32 | [JsonProperty("self_id")] 33 | public long SelfId { get; private set; } 34 | [JsonProperty("user_id")] 35 | public long UserId { get; private set; } 36 | 37 | public abstract Endpoint Endpoint { get; } 38 | 39 | [JsonExtensionData] 40 | public IDictionary ExtensionData { get; private set; } 41 | } 42 | 43 | [JsonObject(MemberSerialization.OptIn)] 44 | public abstract class Request : Post 45 | { 46 | internal const string Friend = "friend"; 47 | internal const string Group = "group"; 48 | 49 | internal new const string TypeField = "request_type"; 50 | 51 | private readonly Lazy _commentLazy; 52 | private readonly Lazy _commentTextLazy; 53 | 54 | internal Request() 55 | { 56 | _commentLazy = new Lazy(() => new ReceivedMessage(ObjComment)); 57 | _commentTextLazy = new Lazy(() => CommentMessage.Text); 58 | } 59 | 60 | [JsonProperty("comment")] 61 | private object ObjComment { get; set; } 62 | 63 | [JsonProperty(TypeField)] 64 | internal string RequestType { get; private set; } 65 | [JsonProperty("flag")] 66 | public string Flag { get; private set; } 67 | public string Comment => _commentTextLazy.Value; 68 | 69 | public ReceivedMessage CommentMessage => _commentLazy.Value; 70 | } 71 | 72 | [JsonObject(MemberSerialization.OptIn)] 73 | public class FriendRequest : Request 74 | { 75 | internal FriendRequest() 76 | { 77 | // ignored 78 | } 79 | 80 | public override Endpoint Endpoint => new PrivateEndpoint(UserId); 81 | } 82 | 83 | [JsonObject(MemberSerialization.OptIn)] 84 | public class GroupRequest : Request 85 | { 86 | internal const string Add = "add"; 87 | internal const string Invite = "invite"; 88 | 89 | internal GroupRequest() 90 | { 91 | // ignored 92 | } 93 | 94 | [JsonProperty(SubTypeField)] 95 | internal string SubType { get; private set; } 96 | 97 | [JsonProperty("group_id")] 98 | public long GroupId { get; private set; } 99 | 100 | public override Endpoint Endpoint => new GroupEndpoint(GroupId); 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/ReceivedMessage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.Immutable; 4 | using System.Linq; 5 | using System.Net.Http; 6 | using System.Text; 7 | using System.Text.RegularExpressions; 8 | using Newtonsoft.Json.Linq; 9 | using static Sisters.WudiLib.SectionMessage; 10 | 11 | namespace Sisters.WudiLib.Posts 12 | { 13 | /// 14 | /// 从上报中收到的消息。 15 | /// 16 | public sealed class ReceivedMessage : WudiLib.Message 17 | { 18 | /// 19 | /// 也在 中提到。 20 | /// 21 | private const string CqCodePattern = @"\[CQ:([\w\-\.]+?)(?:,([\w\-\.]+?)=(.+?))*\]"; 22 | private static readonly Regex CqCodeRegex = new Regex(CqCodePattern, RegexOptions.Compiled); 23 | 24 | private readonly bool _isString; 25 | /// 26 | /// 如果上报格式是 string,则表示原始内容;否则为 null。 27 | /// 28 | private readonly string _message; 29 | 30 | private readonly IReadOnlyList
_sections; 31 | 32 | private readonly Lazy> _sectionListLazy; 33 | 34 | private IReadOnlyList
SectionListFunction() 35 | { 36 | if (!_isString) 37 | return _sections; 38 | int pos = 0; 39 | var regex = CqCodeRegex; 40 | var result = new List
(); 41 | while (pos < _message.Length) 42 | { 43 | var match = regex.Match(_message, pos); 44 | if (!match.Success) 45 | { 46 | result.Add(Section.Text(_message.Substring(pos).AfterReceive())); 47 | pos = _message.Length; 48 | } 49 | else 50 | { 51 | if (match.Index > pos) 52 | { 53 | result.Add(Section.Text(_message.Substring(pos, match.Index - pos))); 54 | } 55 | pos = match.Index + match.Length; 56 | 57 | string type = match.Groups[1].Value.AfterReceive(); 58 | var paras = match.Groups[2].Captures.Cast().Zip( 59 | match.Groups[3].Captures.Cast(), 60 | (capKey, capVal) => (capKey.Value.AfterReceive(), capVal.Value.AfterReceive()) 61 | ).ToArray(); 62 | result.Add(new Section(type, paras)); 63 | } 64 | } 65 | return result.AsReadOnly(); 66 | } 67 | 68 | /// 69 | /// 获取 列表。 70 | /// 71 | public IReadOnlyList
Sections => _sectionListLazy.Value; 72 | 73 | /// 传入参数不符合要求。 74 | /// 应为 类型或者 类型。 75 | internal ReceivedMessage(object o) 76 | { 77 | _sectionListLazy = new Lazy>(SectionListFunction); 78 | 79 | if (o is string s) 80 | { 81 | _isString = true; 82 | _message = s; 83 | return; 84 | } 85 | 86 | else if (o is JArray jObjectArray) 87 | { 88 | var sections = jObjectArray.Select(jo => new Section((JObject)jo)); 89 | _sections = sections.ToList().AsReadOnly(); 90 | return; 91 | } 92 | else 93 | { 94 | throw new InvalidOperationException("用于构造消息的对象即不是字符,也不是数组。可能是上报数据有错误。"); 95 | } 96 | } 97 | 98 | internal ReceivedMessage(IReadOnlyList
sections) 99 | { 100 | _sections = sections; 101 | _sectionListLazy = new Lazy>(() => _sections); 102 | } 103 | 104 | /// 105 | /// 获取消息是否是纯文本。 106 | /// 107 | public bool IsPlaintext 108 | { 109 | get 110 | { 111 | if (_isString) 112 | { 113 | return !CqCodeRegex.IsMatch(_message); 114 | } 115 | 116 | return _sections.All(s => s.Type == "text"); 117 | } 118 | } 119 | 120 | protected internal override object Serializing => this.Forward().Serializing; 121 | 122 | /// 123 | /// 获取不经处理的原始消息内容。 124 | /// 125 | public override string Raw => _isString ? _message : GetRaw(_sections); 126 | 127 | /// 128 | /// 获取固定消息。可以将此字符串保存到本地,在任何时候发送时,都可以发送此消息,不用担心缓存被清或者文件过期。 129 | /// 130 | /// 131 | /// 多半是网络错误。 132 | public string Fix() 133 | { 134 | if (_isString) 135 | { 136 | return CqCodeRegex.Replace(_message, m => 137 | { 138 | if (m.Groups[1].Value == Section.ImageType) 139 | { 140 | for (int i = 0; i < m.Groups[2].Captures.Count; i++) 141 | { 142 | if (m.Groups[2].Captures[i].Value == "url") 143 | { 144 | string url = m.Groups[3].Captures[i].Value.AfterReceive(); 145 | return GetFixedImageSection(url); 146 | } 147 | } 148 | } 149 | return m.Value; 150 | }); 151 | } 152 | else 153 | { 154 | var result = new StringBuilder(); 155 | foreach (var section in _sections) 156 | { 157 | if (section.Type == Section.ImageType) 158 | { 159 | result.Append(section.Data.TryGetValue("url", out string url) 160 | ? GetFixedImageSection(url) 161 | : section.Raw); 162 | } 163 | else 164 | { 165 | result.Append(section.Raw); 166 | } 167 | } 168 | return result.ToString(); 169 | } 170 | } 171 | 172 | /// 173 | /// 获取url指向图片的固定消息。 174 | /// 175 | /// 指向图片的url。 176 | /// 可以发送该图片的消息。 177 | /// 网络错误。 178 | /// urlnull 179 | private static string GetFixedImageSection(string url) 180 | { 181 | if (url is null) 182 | { 183 | throw new ArgumentNullException(nameof(url)); 184 | } 185 | 186 | using (var http = new HttpClient()) 187 | { 188 | var imageBytes = http.GetByteArrayAsync(url).Result; 189 | var base64 = Convert.ToBase64String(imageBytes); 190 | return $"[CQ:image,file=base64://{base64.BeforeSend(true)}]"; 191 | } 192 | } 193 | 194 | /// 195 | /// 获取消息的文本部分。 196 | /// 197 | public string Text 198 | { 199 | get 200 | { 201 | return _isString 202 | ? CqCodeRegex.Replace(_message, string.Empty).AfterReceive() 203 | : string.Concat(_sections.Where(s => s.Type == Section.TextType) 204 | .Select(s => s.Data[Section.TextParamName])); 205 | // 下面是新方法,可能更快。 206 | //string newText = _sections 207 | // .Where(s => s.Type == Section.TextType) 208 | // .Aggregate( 209 | // seed: new StringBuilder(), 210 | // func: (sb, s) => sb.Append(s.Data[Section.TextParamName]), 211 | // resultSelector: sb => sb.ToString()); 212 | } 213 | } 214 | 215 | /// 216 | /// 判断消息内容是否是纯文本,如果是纯文本,则获取此文本内容。使用 string 217 | /// 上报类型时比查询两次属性快;array 上报类型时与先查询 属性,再查询 属性没有区别。 219 | /// 220 | /// 如果是纯文本,则为文本内容;否则为 null。 221 | /// 是否为纯文本。 222 | public bool TryGetPlainText(out string text) 223 | { 224 | text = IsPlaintext 225 | ? (_isString ? Raw.AfterReceive() : Text) 226 | : null; 227 | return !(text is null); 228 | } 229 | 230 | /// 231 | /// Merge continuous text sections. 232 | /// 233 | /// A that continuous text sections are merged. 234 | public ReceivedMessage MergeContinuousTextSections() 235 | { 236 | if (_isString) 237 | { 238 | // if the message is string format, the text is always merged. 239 | return this; 240 | } 241 | var mergedSections = _sections.Aggregate((list: new List
(), current: default(string)), 242 | (t, s) => 243 | { 244 | var (list, current) = t; 245 | if (s.Type != Section.TextType) 246 | { 247 | if (current is not null) 248 | { 249 | list.Add(Section.Text(t.current)); 250 | } 251 | list.Add(s); 252 | return (list, null); 253 | } 254 | return (list, current + s.Data[Section.TextParamName]); 255 | }, 256 | t => 257 | { 258 | var (list, current) = t; 259 | if (current is not null) 260 | { 261 | list.Add(Section.Text(t.current)); 262 | } 263 | return list; 264 | }); 265 | return new ReceivedMessage(mergedSections.ToImmutableArray()); 266 | } 267 | 268 | /// 269 | /// 转发:转换成可以发送的格式。 270 | /// 271 | /// 272 | public WudiLib.Message Forward() 273 | { 274 | if (_isString) 275 | { 276 | //string sendingRaw = Regex.Replace( 277 | // _message, 278 | // $@"\[CQ:{Section.ImageType},file=.+?,url=(.+?)\]", 279 | // m => $"[CQ:{Section.ImageType},file={m.Groups[1].Value}]" 280 | //); 281 | //return new RawMessage(sendingRaw); 282 | return new RawMessage(_message); 283 | } 284 | 285 | //return new SendingMessage(_sections.Select(section => 286 | //{ 287 | // if (section.Type != Section.ImageType) return section; 288 | // try 289 | // { 290 | // return Section.NetImage(section.Data["url"]); 291 | // } 292 | // catch (KeyNotFoundException) 293 | // { 294 | // return section; 295 | // } 296 | //}), true); 297 | return new SendingMessage(_sections); 298 | } 299 | 300 | /// 301 | /// 获取 。 302 | /// 303 | /// 列表。如果上报格式不是数组,则为 null 304 | [Obsolete("请使用 Sections 属性。")] 305 | public IReadOnlyList
GetSections() => _isString ? null : new List
(_sections); 306 | } 307 | } 308 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/Responses.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ComponentModel; 3 | using Newtonsoft.Json; 4 | 5 | namespace Sisters.WudiLib.Posts 6 | { 7 | /// 8 | /// 上报响应数据。 9 | /// 10 | [JsonObject(MemberSerialization.OptIn)] 11 | public abstract class Response 12 | { 13 | /// 14 | /// 是否拦截事件(不再让后面的插件处理)。 15 | /// 16 | [JsonProperty("block", DefaultValueHandling = DefaultValueHandling.Ignore)] 17 | public bool Block { get; set; } 18 | } 19 | 20 | /// 21 | /// 请求响应数据。 22 | /// 23 | public class RequestResponse : Response 24 | { 25 | /// 26 | [JsonConstructor] 27 | public RequestResponse() 28 | { 29 | } 30 | 31 | /// 32 | /// 构造同意或者拒绝请求的响应。一般用于同意请求。 33 | /// 34 | /// 是否同意请求。 35 | public RequestResponse(bool approve) => Approve = approve; 36 | /// 37 | /// 是否同意请求。 38 | /// 39 | [JsonProperty("approve", NullValueHandling = NullValueHandling.Ignore)] 40 | public bool? Approve { get; set; } 41 | } 42 | 43 | /// 44 | public sealed class GroupRequestResponse : RequestResponse 45 | { 46 | /// 47 | [JsonConstructor] 48 | public GroupRequestResponse() 49 | { 50 | 51 | } 52 | 53 | /// 54 | /// 构造拒绝请求的响应。 55 | /// 56 | /// 拒绝理由。 57 | public GroupRequestResponse(string reason) 58 | { 59 | Approve = false; 60 | Reason = reason; 61 | } 62 | 63 | /// 64 | /// 拒绝理由(仅在拒绝时有效)。 65 | /// 66 | [JsonProperty("reason", DefaultValueHandling = DefaultValueHandling.Ignore, NullValueHandling = NullValueHandling.Ignore)] 67 | [DefaultValue("")] 68 | public string Reason { get; set; } 69 | 70 | /// 71 | /// 从 转换 。 72 | /// 73 | /// 74 | public static implicit operator GroupRequestResponse(bool approve) 75 | { 76 | return new GroupRequestResponse 77 | { 78 | Approve = approve, 79 | }; 80 | } 81 | 82 | /// 83 | /// 从 转换 。转换为拒绝请求。 84 | /// 85 | /// 拒绝理由。 86 | public static implicit operator GroupRequestResponse(string reason) => new GroupRequestResponse(reason); 87 | 88 | } 89 | 90 | /// 91 | /// 加好友请求响应。 92 | /// 93 | public sealed class FriendRequestResponse : RequestResponse 94 | { 95 | /// 96 | /// 构造加好友响应。 97 | /// 98 | [JsonConstructor] 99 | public FriendRequestResponse() 100 | { 101 | } 102 | 103 | /// 104 | /// 构造接受请求响应。 105 | /// 106 | /// 好友备注。 107 | public FriendRequestResponse(string remark) 108 | { 109 | Approve = true; 110 | Remark = remark; 111 | } 112 | 113 | /// 114 | /// 添加后的好友备注(仅在同意时有效)。 115 | /// 116 | [JsonProperty("remark", DefaultValueHandling = DefaultValueHandling.Ignore, NullValueHandling = NullValueHandling.Ignore)] 117 | [DefaultValue("")] 118 | public string Remark { get; set; } 119 | 120 | /// 121 | /// 从 转换 。 122 | /// 123 | /// 124 | public static implicit operator FriendRequestResponse(bool approve) 125 | { 126 | return new FriendRequestResponse 127 | { 128 | Approve = approve, 129 | }; 130 | } 131 | } 132 | 133 | internal class MessageResponse : Response 134 | { 135 | public WudiLib.Message Reply { get; set; } 136 | 137 | [JsonProperty("reply")] 138 | private object _reply => Reply?.Serializing; 139 | } 140 | 141 | internal class MultiMessageResponse : MessageResponse 142 | { 143 | [JsonProperty("at_sender")] 144 | public bool AtSender { get; set; } 145 | } 146 | 147 | internal sealed class GroupMessageResponse : MultiMessageResponse 148 | { 149 | [JsonProperty("delete")] 150 | public bool Recall { get; set; } 151 | 152 | [JsonProperty("kick")] 153 | public bool Kick { get; set; } 154 | 155 | [JsonProperty("ban")] 156 | public bool Ban { get; set; } 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Posts/SenderInfo.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using Sisters.WudiLib.Responses; 3 | using static Sisters.WudiLib.Responses.GroupMemberInfo; 4 | 5 | namespace Sisters.WudiLib.Posts 6 | { 7 | /// 群消息发送人信息。需要 CoolQ HTTP API 插件版本 >= 4.5.0,部分字段需要 >= 4.7.0。 8 | public sealed class SenderInfo 9 | { 10 | /// 发送者 QQ 号。 11 | [JsonProperty("user_id")] public long UserId { get; private set; } 12 | /// 性别。可能会在以后改为枚举。 13 | [JsonProperty("sex")] public Sex Sex { get; private set; } 14 | 15 | /// 昵称。 16 | [JsonProperty("nickname")] public string Nickname { get; private set; } 17 | /// 年龄。 18 | [JsonProperty("age")] public int Age { get; private set; } 19 | 20 | /// 群名片/备注。 21 | [JsonProperty("card")] public string InGroupName { get; private set; } 22 | /// 地区。需要 CoolQ HTTP API 插件版本 >= 4.7.0。 23 | [JsonProperty("area")] public string Area { get; private set; } 24 | /// 成员等级。需要 CoolQ HTTP API 插件版本 >= 4.7.0。 25 | [JsonProperty("level")] public string Level { get; private set; } 26 | 27 | /// 角色。需要 CoolQ HTTP API 插件版本 >= 4.7.0。 28 | [JsonProperty("role"), JsonConverter(typeof(AuthorityConverter))] 29 | public GroupMemberAuthority Authority { get; private set; } 30 | 31 | /// 专属头衔。需要 CoolQ HTTP API 插件版本 >= 4.7.0。 32 | [JsonProperty("title")] public string Title { get; private set; } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Properties/PublishProfiles/FolderProfile.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 5 | 6 | 7 | Release 8 | Any CPU 9 | bin\Release\netstandard2.0\publish\ 10 | FileSystem 11 | 12 | -------------------------------------------------------------------------------- /Sisters.WudiLib/RawMessage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Sisters.WudiLib 4 | { 5 | public class RawMessage : Message 6 | { 7 | private readonly string _raw; 8 | 9 | public RawMessage(string raw) => _raw = raw ?? throw new ArgumentNullException(nameof(raw)); 10 | 11 | public override string Raw => _raw; 12 | 13 | protected internal override object Serializing => _raw; 14 | 15 | public static RawMessage operator +(RawMessage left, RawMessage right) 16 | => new RawMessage(left._raw + right._raw); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/CqHttpApiResponse.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Sisters.WudiLib.Responses 5 | { 6 | /// 7 | /// 包含泛型的响应。 8 | /// 9 | /// 响应数据类型。 10 | public class CqHttpApiResponse : CqHttpApiResponse 11 | { 12 | [JsonProperty("data")] 13 | public T Data { get; set; } 14 | } 15 | 16 | /// 17 | /// 响应。 18 | /// 19 | public class CqHttpApiResponse 20 | { 21 | public const int RetcodeOK = 0; 22 | public static System.Collections.ObjectModel.ReadOnlyCollection AcceptableRetcodes { get; } = new List 23 | { 24 | 0, 25 | 1, 26 | }.AsReadOnly(); 27 | 28 | /// 29 | /// 如果 0,则为 true;否则为 false。 30 | /// 31 | public bool IsOk => RetcodeOK == Retcode; 32 | /// 33 | /// 如果 01,则为 true;否则为 false。 34 | /// 35 | public bool IsAcceptableStatus => AcceptableRetcodes.Contains(Retcode); 36 | 37 | [JsonProperty("retcode")] 38 | public int Retcode { get; set; } 39 | [JsonProperty("status")] 40 | public string Status { get; set; } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/Friend.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.Serialization; 2 | 3 | namespace Sisters.WudiLib.Responses 4 | { 5 | /// 6 | /// 好友。 7 | /// 8 | [DataContract] 9 | public sealed class Friend 10 | { 11 | /// 12 | /// QQ 号。 13 | /// 14 | [DataMember(Name = "user_id")] 15 | public long UserId { get; internal set; } 16 | 17 | /// 18 | /// 备注或昵称。 19 | /// 20 | [DataMember(Name = "remark")] 21 | public string Remark { get; internal set; } 22 | 23 | /// 24 | /// 昵称。 25 | /// 26 | [DataMember(Name = "nickname")] 27 | public string Nickname { get; internal set; } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/GetMessageResponseData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | using Newtonsoft.Json.Converters; 4 | using Sisters.WudiLib.Posts; 5 | 6 | namespace Sisters.WudiLib.Responses; 7 | #nullable enable 8 | /// 9 | /// 获取消息 API 的响应数据。 10 | /// 11 | public class GetMessageResponseData 12 | { 13 | [JsonProperty("message")] 14 | private object _message = default!; 15 | [JsonIgnore] 16 | private readonly Lazy _messageLazy; 17 | [JsonIgnore] 18 | private bool _isMessageManualSet; 19 | 20 | /// 21 | /// 构造实例。 22 | /// 23 | public GetMessageResponseData() 24 | { 25 | _messageLazy = new Lazy(() => new ReceivedMessage(_message)); 26 | } 27 | 28 | /// 29 | /// 发送时间。 30 | /// 31 | [JsonConverter(typeof(UnixDateTimeConverter))] 32 | [JsonProperty("time")] 33 | public DateTimeOffset Time { get; set; } 34 | /// 35 | /// 消息类型。 36 | /// 37 | [JsonProperty("message_type")] 38 | public required string MessageType { get; set; } 39 | /// 40 | /// 消息id。 41 | /// 42 | [JsonProperty("message_id")] 43 | public int MessageId { get; set; } 44 | /// 45 | /// 消息真实id。 46 | /// 47 | [JsonProperty("real_id")] 48 | public int RealId { get; set; } 49 | /// 50 | /// 发送人信息。 51 | /// 52 | [JsonProperty("sender")] 53 | public required SenderInfo Sender { get; set; } 54 | /// 55 | /// 消息内容。 56 | /// 57 | [JsonIgnore] 58 | public ReceivedMessage Message 59 | { 60 | get => _isMessageManualSet ? (ReceivedMessage)_message : _messageLazy.Value; 61 | set 62 | { 63 | _isMessageManualSet = true; 64 | _message = value; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/GroupInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | using Newtonsoft.Json; 5 | 6 | namespace Sisters.WudiLib.Responses 7 | { 8 | /// 9 | /// get_group_list 返回的群信息。 10 | /// 11 | public class GroupInfo 12 | { 13 | /// 14 | /// 群号。 15 | /// 16 | [JsonProperty("group_id")] 17 | public long Id { get; set; } 18 | 19 | /// 20 | /// 群名称。 21 | /// 22 | [JsonProperty("group_name")] 23 | public string Name { get; set; } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/GroupMemberInfo.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Newtonsoft.Json; 5 | using Newtonsoft.Json.Converters; 6 | 7 | #pragma warning disable CS1591 8 | 9 | namespace Sisters.WudiLib.Responses 10 | { 11 | [JsonObject(MemberSerialization.OptIn)] 12 | public sealed class GroupMemberInfo 13 | { 14 | [JsonProperty("group_id")] 15 | public long GroupId { get; internal set; } 16 | [JsonProperty("user_id")] 17 | public long UserId { get; internal set; } 18 | [JsonProperty("nickname")] 19 | public string Nickname { get; internal set; } 20 | [JsonProperty("card")] 21 | public string InGroupName { get; internal set; } 22 | 23 | public string DisplayName => string.IsNullOrEmpty(InGroupName) ? Nickname : InGroupName; 24 | 25 | /// 26 | /// 性别。 27 | /// 28 | [JsonProperty("sex"), JsonConverter(typeof(StringEnumConverter))] 29 | public Sex Sex { get; private set; } 30 | 31 | [JsonProperty("age")] 32 | public int Age { get; internal set; } 33 | 34 | /// 35 | /// 地区。 中无法获取。 36 | /// 37 | [JsonProperty("area")] 38 | public string Area { get; internal set; } 39 | 40 | [JsonProperty("join_time"), JsonConverter(typeof(UnixDateTimeConverter))] 41 | public DateTimeOffset JoinTime { get; internal set; } 42 | 43 | [JsonProperty("last_sent_time"), JsonConverter(typeof(UnixDateTimeConverter))] 44 | public DateTimeOffset LastSendTime { get; internal set; } 45 | 46 | /// 47 | /// 成员等级。 中无法获取。 48 | /// 49 | [JsonProperty("level")] 50 | public string Level { get; private set; } 51 | 52 | [JsonProperty("role"), JsonConverter(typeof(AuthorityConverter))] 53 | public GroupMemberAuthority Authority { get; internal set; } 54 | 55 | /// 56 | /// 是否不良记录成员。 57 | /// 58 | [JsonProperty("unfriendly")] 59 | public bool IsUnfriendly { get; private set; } 60 | 61 | /// 62 | /// 专属头衔。 中无法获取。 63 | /// 64 | [JsonProperty("title")] 65 | public string Title { get; internal set; } 66 | 67 | // title_expire_time 68 | 69 | [JsonProperty("card_changeable")] 70 | public bool IsCardChangeable { get; internal set; } 71 | 72 | public override string ToString() => DisplayName; 73 | 74 | public enum GroupMemberAuthority 75 | { 76 | Unknown = 0, 77 | Normal = 1, 78 | Manager = 2, 79 | Leader = 3, 80 | } 81 | 82 | internal class AuthorityConverter : JsonConverter 83 | { 84 | private static readonly IReadOnlyDictionary List = 85 | new Dictionary 86 | { 87 | { "member", GroupMemberAuthority.Normal }, 88 | { "admin", GroupMemberAuthority.Manager }, 89 | { "owner", GroupMemberAuthority.Leader }, 90 | }; 91 | 92 | public override bool CanConvert(Type objectType) => objectType == typeof(GroupMemberAuthority); 93 | 94 | public override object ReadJson(JsonReader reader, Type objectType, object existingValue, 95 | JsonSerializer serializer) 96 | { 97 | return reader.TokenType == JsonToken.String 98 | ? List.GetValueOrDefault(reader.Value.ToString(), GroupMemberAuthority.Unknown) 99 | : GroupMemberAuthority.Unknown; 100 | } 101 | 102 | public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) 103 | { 104 | var result = from e in List 105 | where e.Value == value as GroupMemberAuthority? 106 | select e.Key; 107 | if (!result.Any()) writer.WriteNull(); 108 | else writer.WriteValue(result.First()); 109 | } 110 | } 111 | } 112 | } 113 | 114 | #pragma warning restore CS1591 -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/LoginInfo.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace Sisters.WudiLib.Responses 7 | { 8 | /// 9 | /// 包含 QQ 号和昵称的登录信息 10 | /// 11 | public sealed class LoginInfo 12 | { 13 | private LoginInfo() 14 | { 15 | 16 | } 17 | 18 | [JsonProperty("user_id")] 19 | public long UserId { get; internal set; } 20 | 21 | [JsonProperty("nickname")] 22 | public string Nickname { get; internal set; } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/SendDiscussMessageResponseData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Sisters.WudiLib.Responses 6 | { 7 | /// 8 | /// 发送讨论组消息后返回的信息 9 | /// 10 | public sealed class SendDiscussMessageResponseData : SendMessageResponseData 11 | { 12 | private SendDiscussMessageResponseData() 13 | { 14 | 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/SendGroupMessageResponseData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Text; 4 | 5 | namespace Sisters.WudiLib.Responses 6 | { 7 | /// 8 | /// 发送群消息后返回的信息 9 | /// 10 | public sealed class SendGroupMessageResponseData : SendMessageResponseData 11 | { 12 | private SendGroupMessageResponseData() 13 | { 14 | 15 | } 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/SendMessageResponseData.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace Sisters.WudiLib.Responses 7 | { 8 | /// 9 | /// 发送消息后返回的数据 10 | /// 11 | public class SendMessageResponseData 12 | { 13 | internal SendMessageResponseData() 14 | { 15 | 16 | } 17 | 18 | /// 19 | /// 消息 ID 20 | /// 21 | [JsonProperty("message_id")] 22 | public int MessageId { get; internal set; } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/SendPrivateMessageResponseData.cs: -------------------------------------------------------------------------------- 1 | using Newtonsoft.Json; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | 6 | namespace Sisters.WudiLib.Responses 7 | { 8 | /// 9 | /// 发送私聊消息后返回的信息 10 | /// 11 | public sealed class SendPrivateMessageResponseData : SendMessageResponseData 12 | { 13 | private SendPrivateMessageResponseData() 14 | { 15 | 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/Sex.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.Serialization; 2 | 3 | namespace Sisters.WudiLib.Responses 4 | { 5 | /// 6 | /// 性别。 7 | /// 8 | public enum Sex 9 | { 10 | /// 11 | /// 未知。 12 | /// 13 | [EnumMember(Value = "unknown")] 14 | Unknown = 0, 15 | /// 16 | /// 男性。 17 | /// 18 | [EnumMember(Value = "male")] 19 | Male = 1, 20 | /// 21 | /// 女性。 22 | /// 23 | [EnumMember(Value = "female")] 24 | Female = 2, 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Responses/Status.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Newtonsoft.Json; 3 | 4 | namespace Sisters.WudiLib.Responses 5 | { 6 | /// 7 | /// 表示 CQ HTTP 插件运行状态。 8 | /// 9 | public class Status 10 | { 11 | /// 12 | /// HTTP API 插件已启用。 13 | /// 14 | [JsonProperty("app_enabled")] 15 | public bool AppEnabled { get; set; } 16 | 17 | /// 18 | /// HTTP API 插件正常运行(已初始化、已启用、各内部插件正常运行)。 19 | /// 20 | [JsonProperty("app_good")] 21 | public bool AppGood { get; set; } 22 | 23 | /// 24 | /// HTTP API 插件已初始化。 25 | /// 26 | [JsonProperty("app_initialized")] 27 | public bool AppInitialized { get; set; } 28 | 29 | /// 30 | /// HTTP API 插件状态符合预期,意味着插件已初始化,内部插件都在正常运行,且 QQ 在线。 31 | /// 32 | [JsonProperty("good")] 33 | public bool Good { get; set; } 34 | 35 | /// 36 | /// 当前 QQ 在线,null 表示无法查询到在线状态。 37 | /// 38 | [JsonProperty("online")] 39 | public bool? Online { get; set; } 40 | 41 | /// 42 | /// HTTP API 的各内部插件是否正常运行。 43 | /// 44 | [JsonProperty("plugins_good")] 45 | public IDictionary PluginsGood { get; set; } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sisters.WudiLib/Section.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.IO; 5 | using System.Linq; 6 | using System.Runtime.InteropServices; 7 | using System.Text; 8 | using System.Text.RegularExpressions; 9 | using System.Web; 10 | using Newtonsoft.Json; 11 | 12 | namespace Sisters.WudiLib 13 | { 14 | /// 15 | /// 消息段。 16 | /// 17 | [JsonObject(MemberSerialization.OptIn)] 18 | public sealed class Section : IEquatable
19 | { 20 | public const string TextParamName = "text"; 21 | public const string TextType = "text"; 22 | public const string ImageType = "image"; 23 | public const string RecordType = "record"; 24 | public const string MusicType = "music"; 25 | public const string AtType = "at"; 26 | 27 | /// 28 | /// 仅支持大小写字母、数字、短横线(-)、下划线(_)及点号(.)。 29 | /// 30 | [JsonProperty("type")] 31 | public string Type { get; } 32 | 33 | [JsonProperty("data")] 34 | public IReadOnlyDictionary Data { get; } 35 | 36 | [Obsolete("请改用 Data 属性", true)] 37 | public IReadOnlyDictionary GetData() => Data; 38 | 39 | [JsonIgnore] 40 | public string Raw 41 | { 42 | get 43 | { 44 | if (Type == TextType) 45 | return Data[TextParamName].BeforeSend(false); 46 | var sb = new StringBuilder($"[CQ:{Type}"); 47 | foreach (var param in Data) 48 | { 49 | sb.Append($",{param.Key}={param.Value.BeforeSend(true)}"); 50 | } 51 | 52 | sb.Append("]"); 53 | return sb.ToString(); 54 | } 55 | } 56 | 57 | public bool TryGetText(out string text) 58 | { 59 | bool isText = Type == TextType; 60 | text = isText ? Data.GetValueOrDefault(TextParamName, string.Empty) : default(string); 61 | return isText; 62 | } 63 | 64 | public bool TryGetAtMember(out long qq) 65 | { 66 | bool isAt = Type == AtType; 67 | qq = default(long); 68 | bool atMember = isAt && long.TryParse( 69 | s: Data.GetValueOrDefault("qq"), 70 | style: System.Globalization.NumberStyles.None, 71 | provider: System.Globalization.CultureInfo.InvariantCulture, 72 | result: out qq 73 | ); 74 | return atMember; 75 | } 76 | 77 | /// 78 | /// 被 包含。 79 | /// 80 | private const string CqCodeTypePattern = @"[\w\-\.]+"; 81 | private const string CqCodeTypeStrictPattern = "^" + CqCodeTypePattern + "$"; 82 | private static readonly Regex CqCodeTypeCheckRegex = new Regex(CqCodeTypeStrictPattern, RegexOptions.Compiled); 83 | 84 | /// 类型或key不符合CQ码规范。 85 | private void CheckArguments(string typeParamName, string dataParamName) 86 | { 87 | const string Message = @"CQ码中的function(功能名)与key(参数名),仅支持大小写字母、数字、短横线(-)、下划线(_)及点号(.)。 88 | 详见:https://d.cqp.me/Pro/CQ码"; 89 | if (!CqCodeTypeCheckRegex.IsMatch(Type)) 90 | { 91 | throw new ArgumentException(Message, typeParamName); 92 | } 93 | if (!Data.Keys.All(k => CqCodeTypeCheckRegex.IsMatch(k))) 94 | { 95 | throw new ArgumentException(Message, dataParamName); 96 | } 97 | } 98 | 99 | /// data or type was null. 100 | /// 类型或key不符合CQ码规范。 101 | [JsonConstructor] 102 | public Section(string type, IReadOnlyDictionary data) 103 | { 104 | if (data == null) 105 | { 106 | throw new ArgumentNullException(nameof(data)); 107 | } 108 | 109 | Type = type ?? throw new ArgumentNullException(nameof(type)); 110 | Data = data.ToDictionary(p => p.Key, p => p.Value); 111 | CheckArguments(nameof(type), nameof(data)); 112 | } 113 | 114 | /// data or type was null. 115 | /// 类型或key不符合CQ码规范。 116 | public Section(string type, params (string key, string value)[] data) 117 | { 118 | if (data == null) 119 | { 120 | throw new ArgumentNullException(nameof(data)); 121 | } 122 | 123 | this.Type = type ?? throw new ArgumentNullException(nameof(type)); 124 | var dataDictionary = new Dictionary(); 125 | Array.ForEach(data, pa => dataDictionary.Add(pa.key, pa.value)); 126 | this.Data = new ReadOnlyDictionary(dataDictionary); 127 | 128 | CheckArguments(nameof(type), nameof(data)); 129 | } 130 | 131 | /// 132 | /// 133 | internal Section(Newtonsoft.Json.Linq.JToken jObject) 134 | { 135 | try 136 | { 137 | string type = jObject.Value("type"); 138 | Type = type; 139 | Data = jObject["data"].ToObject>() 140 | ?? new ReadOnlyDictionary(new Dictionary()); 141 | } 142 | catch (Exception exception) 143 | { 144 | throw new InvalidOperationException("构造消息段失败。\r\n" + jObject.ToString(), exception); 145 | } 146 | } 147 | 148 | /// 149 | public override bool Equals(object obj) => this.Equals(obj as Section); 150 | 151 | /// 152 | /// 确定给定消息段是否等于当前消息段。 153 | /// 154 | public bool Equals(Section other) 155 | { 156 | if (other is null) 157 | return false; 158 | if (this.Type != other.Type) 159 | return false; 160 | if (this.Data.Count != other.Data.Count) 161 | return false; 162 | foreach (var param in Data.OrderBy(p => p.Key)) 163 | { 164 | string key = param.Key; 165 | if (other.Data.TryGetValue(key, out string otherValue)) 166 | if (param.Value == otherValue) 167 | continue; 168 | return false; 169 | } 170 | 171 | return true; 172 | } 173 | 174 | public override int GetHashCode() 175 | { 176 | var hashCode = -628614918; 177 | hashCode = hashCode * -1521134295 + EqualityComparer.Default.GetHashCode(this.Type); 178 | foreach (var param in Data.OrderBy(p => p.Key)) 179 | { 180 | hashCode = hashCode * -1521134295 + 181 | EqualityComparer>.Default.GetHashCode(param); 182 | } 183 | 184 | //hashCode = hashCode * -1521134295 + EqualityComparer>.Default.GetHashCode(this.data); 185 | return hashCode; 186 | } 187 | 188 | /// 189 | /// 将消息段转换为 。如果类型为 ,则为文本内容(不转义);否则与 CQ 码形式相同。 190 | /// 191 | /// 转换后的 192 | public override string ToString() => Type == TextType ? Data[TextParamName] : Raw; 193 | 194 | /// 195 | /// 构造文本消息段。 196 | /// 197 | /// 198 | /// 199 | internal static Section Text(string text) => new Section(TextType, (TextParamName, text)); 200 | 201 | /// 202 | /// 构造 At 消息段。 203 | /// 204 | /// 205 | /// 206 | internal static Section At(long qq) => new Section(AtType, ("qq", qq.ToString())); 207 | 208 | /// 209 | /// 构造 At 全体成员消息段。 210 | /// 211 | /// 212 | internal static Section AtAll() => new Section(AtType, ("qq", "all")); 213 | 214 | /// 215 | /// 构造本地图片消息段。 216 | /// 217 | /// 218 | /// 219 | internal static Section LocalImage(string file) 220 | { 221 | try 222 | { 223 | return new Section(ImageType, ("file", CreateFileUri(file))); 224 | } 225 | catch (UriFormatException e) 226 | { 227 | throw new FormatException("file 不是合法的路径", e); 228 | } 229 | } 230 | 231 | internal static Section ByteArrayImage(byte[] bytes) => new Section(ImageType, ("file", $"base64://{Convert.ToBase64String(bytes)}")); 232 | 233 | /// 234 | /// 构造网络图片消息段。 235 | /// 236 | /// 237 | /// 238 | internal static Section NetImage(string url) => new Section(ImageType, ("file", url)); 239 | 240 | /// 241 | /// 构造网络图片消息段。可以指定是否使用缓存。 242 | /// 243 | /// 244 | /// 是否使用缓存。 245 | /// 246 | internal static Section NetImage(string url, bool noCache) 247 | => noCache ? new Section(ImageType, ("cache", "0"), ("file", url)) : NetImage(url); 248 | 249 | #nullable enable 250 | internal static Section LocalRecord(string file) => new Section(RecordType, ("file", CreateFileUri(file))); 251 | 252 | internal static Section ByteArrayRecord(byte[] bytes) => new Section(RecordType, ("file", $"base64://{Convert.ToBase64String(bytes)}")); 253 | #nullable restore 254 | 255 | internal static Section NetRecord(string url) => new Section(RecordType, ("file", url)); 256 | 257 | internal static Section NetRecord(string url, bool noCache) 258 | { 259 | return noCache ? new Section(RecordType, ("cache", "0"), ("file", url)) : NetRecord(url); 260 | } 261 | 262 | /// 263 | /// 构造音乐自定义分享消息段。 264 | /// 265 | /// 分享链接,即点击分享后进入的音乐页面(如歌曲介绍页)。 266 | /// 音频链接(如mp3链接)。 267 | /// 音乐的标题,建议12字以内。 268 | /// 音乐的简介,建议30字以内。该参数可被忽略。 269 | /// 音乐的封面图片链接。若参数为空或被忽略,则显示默认图片。 270 | /// introductionUrlaudioUrltitle为空。 271 | /// introductionUrlaudioUrltitlenull 272 | /// 273 | internal static Section MusicCustom(string introductionUrl, string audioUrl, string title, string profile, 274 | string imageUrl) 275 | { 276 | const string introductionUrlParamName = "url"; 277 | const string audioUrlParamName = "audio"; 278 | const string titleParamName = "title"; 279 | const string profileParamName = "content"; 280 | const string imageUrlParamName = "image"; 281 | Utilities.CheckStringArgument(introductionUrl, nameof(introductionUrl)); 282 | Utilities.CheckStringArgument(audioUrl, nameof(audioUrl)); 283 | Utilities.CheckStringArgument(title, nameof(title)); 284 | var arguments = new List<(string argument, string value)> 285 | { 286 | ("type", "custom"), 287 | (introductionUrlParamName, introductionUrl), 288 | (audioUrlParamName, audioUrl), 289 | (titleParamName, title), 290 | }; 291 | if (profile != null) 292 | arguments.Add((profileParamName, profile)); 293 | if (!string.IsNullOrEmpty(imageUrl)) 294 | arguments.Add((imageUrlParamName, imageUrl)); 295 | return new Section(MusicType, arguments.ToArray()); 296 | } 297 | 298 | internal static Section Shake() => new Section("shake"); 299 | 300 | #nullable enable 301 | private static string CreateFileUri(string file) 302 | { 303 | return (file.StartsWith("/", StringComparison.Ordinal), Path.IsPathRooted(file), RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) switch 304 | { 305 | (_, false, _) => file, 306 | (_, true, false) => new Uri(file).AbsoluteUri, 307 | (false, true, true) => new Uri(file).AbsoluteUri, 308 | (true, true, true) => "file://" + Uri.EscapeUriString(file).Replace("?", Uri.HexEscape('?')), 309 | }; 310 | } 311 | #nullable restore 312 | 313 | public static bool operator ==(Section left, Section right) 314 | => left is null ? right is null : left.Equals(right); 315 | 316 | public static bool operator !=(Section left, Section right) => !(left == right); 317 | } 318 | } -------------------------------------------------------------------------------- /Sisters.WudiLib/SectionMessage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | 5 | namespace Sisters.WudiLib 6 | { 7 | #nullable enable 8 | /// 9 | /// 旧设计,正计划修改。 10 | /// 11 | public abstract class SectionMessage : Message 12 | { 13 | private protected SectionMessage() => SectionsBase = new List
().AsReadOnly(); 14 | 15 | private protected SectionMessage(IEnumerable
? sections) 16 | => this.SectionsBase = new List
(sections ?? Enumerable.Empty
()).AsReadOnly(); 17 | 18 | /// 19 | private protected SectionMessage(params Section[] sections) : this(sections as IEnumerable
) 20 | { } 21 | 22 | protected virtual IReadOnlyList
SectionsBase { get; } 23 | 24 | public override string Raw => GetRaw(SectionsBase); 25 | 26 | internal static string GetRaw(IEnumerable
sections) 27 | => string.Concat(sections.Select(section => section.Raw)); 28 | } 29 | #nullable restore 30 | } -------------------------------------------------------------------------------- /Sisters.WudiLib/SendingMessage.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | 6 | namespace Sisters.WudiLib 7 | { 8 | /// 9 | /// 表示将要发送的消息。 10 | /// 11 | public class SendingMessage : SectionMessage 12 | { 13 | private static readonly ICollection NoJoinSectionTypes = new List 14 | { 15 | "record", 16 | "rps", 17 | "dice", 18 | "music", 19 | "share", 20 | }; 21 | 22 | public IReadOnlyList
Sections => SectionsBase; 23 | 24 | protected internal override object Serializing => SectionsBase; 25 | 26 | /// 27 | /// 指示此 是否可以与其他 连接。 28 | /// 29 | private bool CanConcat => !Sections.Any(s => NoJoinSectionTypes.Contains(s.Type)); 30 | 31 | /// 32 | /// 构造新的消息实例。 33 | /// 34 | public SendingMessage() : base() 35 | { 36 | // ignored 37 | } 38 | 39 | #nullable enable 40 | /// 41 | /// 从 创建消息。 42 | /// 43 | /// 44 | internal SendingMessage(IEnumerable
sections) : base(sections) 45 | { 46 | // ignored 47 | } 48 | #nullable restore 49 | 50 | /// 51 | /// 从文本构造新的消息实例。 52 | /// 53 | /// 消息内容文本。 54 | public SendingMessage(string text) : base(Section.Text(text)) 55 | { } 56 | 57 | #nullable enable 58 | /// 59 | /// 从两个 实例创建消息。 60 | /// 61 | /// 在前面的消息。 62 | /// 在后面的消息。 63 | /// 有无法连接的消息。 64 | private SendingMessage(SendingMessage? message1, SendingMessage? message2) : this( 65 | message1?.Sections.Concat(message2?.Sections ?? Enumerable.Empty
()) ?? message2?.Sections ?? Enumerable.Empty
()) 66 | { 67 | if (message1?.CanConcat == false || message2?.CanConcat == false) 68 | { 69 | throw new InvalidOperationException("有一个或多个消息不能被连接。"); 70 | } 71 | } 72 | #nullable restore 73 | 74 | /// 75 | /// 从 实例创建消息。 76 | /// 77 | /// 包含的消息段。 78 | public SendingMessage(Section section) : base(section) 79 | { } 80 | 81 | #nullable enable 82 | public SendingMessage(params Section[] sections) : base(sections) 83 | { 84 | if (SectionsBase.Contains(null!)) 85 | { 86 | throw new ArgumentException("传入的消息段数组不能含有 null 值。"); 87 | } 88 | } 89 | 90 | #if NET6_0_OR_GREATER 91 | public static SendingMessage FromInterpolated(MessageInterpolatedStringHandler builder) 92 | { 93 | return builder.GetFormattedMessage(); 94 | } 95 | #endif 96 | #nullable restore 97 | 98 | /// 99 | /// 构造 At 群、讨论组成员消息。 100 | /// 101 | /// 要 At 的 QQ 号。 102 | /// 构造的消息。 103 | public static SendingMessage At(long qq) => new SendingMessage(Section.At(qq)); 104 | 105 | /// 106 | /// 构造 At 群、讨论组全体成员的消息。 107 | /// 108 | /// 构造的消息。 109 | public static SendingMessage AtAll() => new SendingMessage(Section.AtAll()); 110 | 111 | /// 112 | /// 构造包含本地图片的消息。 113 | /// 114 | /// 本地图片的路径。 115 | /// 构造的消息。 116 | public static SendingMessage LocalImage(string path) => new SendingMessage(Section.LocalImage(path)); 117 | 118 | /// 119 | /// 构造包含本地图片的消息。可以把文件转换成 base64 形式,以便在其他机器上发送。 120 | /// 121 | /// 本地图片的路径。 122 | /// 是否要把图片消息转换为 base64 形式。 123 | /// 详见 所引发的异常。 124 | /// 构造的消息。 125 | public static SendingMessage LocalImage(string path, bool convertToBase64 = false) 126 | => convertToBase64 ? ByteArrayImage(File.ReadAllBytes(path)) : LocalImage(path); 127 | 128 | /// 129 | /// 从 数组构造消息。 130 | /// 131 | /// 图片 数组。 132 | /// bytesnull 133 | /// 构造的消息。 134 | public static SendingMessage ByteArrayImage(byte[] bytes) 135 | { 136 | if (bytes is null) 137 | { 138 | throw new ArgumentNullException(nameof(bytes)); 139 | } 140 | 141 | return new SendingMessage(Section.ByteArrayImage(bytes)); 142 | } 143 | 144 | /// 145 | /// 构造一条消息,包含来自网络的图片。 146 | /// 147 | /// 网络图片 URL。 148 | /// 构造的消息。 149 | public static SendingMessage NetImage(string url) => new SendingMessage(Section.NetImage(url)); 150 | 151 | /// 152 | /// 构造一条消息,包含来自网络的图片。可以指定是否不使用缓存。 153 | /// 154 | /// 网络图片 URL。 155 | /// 是否不使用缓存(默认使用)。 156 | /// 构造的消息。 157 | public static SendingMessage NetImage(string url, bool noCache) => 158 | new SendingMessage(Section.NetImage(url, noCache)); 159 | 160 | #nullable enable 161 | /// 162 | /// 构造包含本地语音的消息。 163 | /// 164 | /// 本地语音的路径或文件 URI。 165 | /// 构造的消息。 166 | public static SendingMessage LocalRecord(string path) => new SendingMessage(Section.LocalRecord(path ?? throw new ArgumentNullException(nameof(path)))); 167 | 168 | public static SendingMessage ByteArrayRecord(byte[] bytes) => new SendingMessage(Section.ByteArrayRecord(bytes ?? throw new ArgumentNullException(nameof(bytes)))); 169 | #nullable restore 170 | 171 | /// 172 | /// 网络语音。 173 | /// 174 | /// 网络语音 URL。 175 | /// 是否不使用缓存(默认使用)。 176 | /// 网络语音消息。 177 | public static SendingMessage NetRecord(string url, bool noCache) => 178 | new SendingMessage(Section.NetRecord(url, noCache)); 179 | 180 | /// 181 | /// 网络语音。 182 | /// 183 | /// 网络语音 URL。 184 | /// 网络语音消息。 185 | public static SendingMessage NetRecord(string url) => new SendingMessage(Section.NetRecord(url)); 186 | 187 | /// 188 | /// 构造一条消息,包含音乐自定义分享,该分享指定了分享链接、音频链接、标题、简介和封面图片链接。 189 | /// 190 | /// 分享链接,即点击分享后进入的音乐页面(如歌曲介绍页)。 191 | /// 音频链接(如mp3链接)。 192 | /// 音乐的标题,建议12字以内。 193 | /// 音乐的简介,建议30字以内。该参数可被忽略。 194 | /// 音乐的封面图片链接。若参数为空或被忽略,则显示默认图片。 195 | /// introductionUrlaudioUrltitle为空。 196 | /// introductionUrlaudioUrltitlenull 197 | /// 包含该音乐自定义分享的消息。 198 | public static SendingMessage MusicCustom(string introductionUrl, string audioUrl, string title, string profile, 199 | string imageUrl) 200 | => new SendingMessage(Section.MusicCustom(introductionUrl, audioUrl, title, profile, imageUrl)); 201 | 202 | /// 203 | /// 构造一条消息,包含音乐自定义分享,该分享指定了分享链接、音频链接和标题。 204 | /// 205 | /// 分享链接,即点击分享后进入的音乐页面(如歌曲介绍页)。 206 | /// 音频链接(如mp3链接)。 207 | /// 音乐的标题,建议12字以内。 208 | /// introductionUrlaudioUrltitle为空。 209 | /// introductionUrlaudioUrltitlenull 210 | /// 包含该音乐自定义分享的消息。 211 | public static SendingMessage MusicCustom(string introductionUrl, string audioUrl, string title) 212 | => new SendingMessage(Section.MusicCustom(introductionUrl, audioUrl, title, null, null)); 213 | 214 | /// 215 | /// 戳一戳。 216 | /// 217 | public static SendingMessage Shake() => new SendingMessage(Section.Shake()); 218 | 219 | #nullable enable 220 | /// 221 | /// 使用 + 连接两条消息。 222 | /// 223 | /// 224 | /// 225 | /// 一个或多个消息不可连接。 226 | /// 227 | public static SendingMessage operator +(SendingMessage? left, SendingMessage? right) => 228 | new SendingMessage(left, right); 229 | #nullable restore 230 | 231 | /// 232 | /// 从字符串转换为消息。 233 | /// 234 | /// 要转换的字符串。 235 | public static implicit operator SendingMessage(string s) => new SendingMessage(s); 236 | } 237 | } -------------------------------------------------------------------------------- /Sisters.WudiLib/Sisters.WudiLib.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netstandard2.0;net7.0 5 | latest 6 | 0.4.2-alpha2 7 | bleatingsheep 8 | bleatingsheep 9 | bleatingsheep 10 | cqhttp coolq-http-api coolq qq qqbot qqrobot 酷Q 11 | https://github.com/int-and-his-friends/Sisters.WudiLib 12 | 13 | 酷Q HTTP API .NET 14 | MIT 15 | 16 | true 17 | snupkg 18 | 19 | 20 | 21 | C:\Users\yinmi\OneDrive\文档\Visual Studio 2017\Projects\Sisters.WudiLib\Sisters.WudiLib\Sisters.WudiLib.xml 22 | 23 | 24 | 25 | 26 | 7.0.0 27 | 28 | 29 | 30 | 31 | 32 | 33 | all 34 | runtime; build; native; contentfiles; analyzers; buildtransitive 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /Sisters.WudiLib/System.Collections.Generic/CollectionExtensions.cs: -------------------------------------------------------------------------------- 1 | // Licensed to the .NET Foundation under one or more agreements. 2 | // The .NET Foundation licenses this file to you under the MIT license. 3 | // See the LICENSE file in the project root for more information. 4 | 5 | namespace System.Collections.Generic 6 | { 7 | #if NETSTANDARD2_0 8 | internal static class CollectionExtensions 9 | { 10 | public static TValue GetValueOrDefault(this IReadOnlyDictionary dictionary, TKey key) 11 | { 12 | return dictionary.GetValueOrDefault(key, default(TValue)); 13 | } 14 | 15 | public static TValue GetValueOrDefault(this IReadOnlyDictionary dictionary, TKey key, TValue defaultValue) 16 | { 17 | if (dictionary is null) 18 | { 19 | throw new ArgumentNullException(nameof(dictionary)); 20 | } 21 | 22 | TValue value; 23 | return dictionary.TryGetValue(key, out value) ? value : defaultValue; 24 | } 25 | 26 | public static bool TryAdd(this IDictionary dictionary, TKey key, TValue value) 27 | { 28 | if (dictionary is null) 29 | { 30 | throw new ArgumentNullException(nameof(dictionary)); 31 | } 32 | 33 | if (!dictionary.ContainsKey(key)) 34 | { 35 | dictionary.Add(key, value); 36 | return true; 37 | } 38 | 39 | return false; 40 | } 41 | 42 | public static bool Remove(this IDictionary dictionary, TKey key, out TValue value) 43 | { 44 | if (dictionary is null) 45 | { 46 | throw new ArgumentNullException(nameof(dictionary)); 47 | } 48 | 49 | if (dictionary.TryGetValue(key, out value)) 50 | { 51 | dictionary.Remove(key); 52 | return true; 53 | } 54 | 55 | value = default(TValue); 56 | return false; 57 | } 58 | } 59 | #endif 60 | } 61 | -------------------------------------------------------------------------------- /Sisters.WudiLib/TODO.md: -------------------------------------------------------------------------------- 1 | ## Recent 2 | - `SectionMessage` 中的属性改为抽象,在子类实现。`GetRaw()` 移到 `Section` 类? 3 | 4 | - WS可选去除首次连接必须成功的限制。 5 | 6 | ## Future 7 | - 优化基础类 8 | 9 | 4. 不再使用 `Utilities` 类。 10 | 11 | - 优化整个事件处理流程 12 | 13 | 1. 将反序列化与事件处理分离,便于实现共同事件。可以通过 `is` 运算符判断事件类型。 14 | 2. 分离数据传输、反序列化及业务逻辑的代码,使得在不同阶段发生的异常可以由不同事件处理。 15 | 16 | - 修改异常、客户端和上报监听器的类名,使其更符合逻辑。暂定 `CqHttpClient`/`CqHttpListener`/`CqHttpException` 17 | - 新增虚函数 `OnSendingRequest`,减少无效重写。 18 | - 优化 `Message` 类的继承结构。 19 | 20 | 1. 可以实现 `IEnumerable` 等接口,这样应该可以直接序列化。 21 | 2. 设计 `IMessage` 接口,发消息时传入。暂定有 `Serializing`、`Raw` 等属性。 22 | 3. 修改 `Post.Message` 类名,减少同时 `using` 两个命名空间时的麻烦。暂定一律改为 `PostContext`、`MessageContext` 等。 23 | 4. `Message` 类为构造的消息;`RawMessage` 类不变(但不再继承 `Message`,`ReceivedMessage` 也是);取消冗余的 `SectionMessage` 和 `SendingMessage`;`ReceivedMessage` 保留 `Raw`、`Sections`(可能改为 `SectionMessage`,毕竟 `Message` 实现了 `IEnumerable`)等属性,可判等;其余的消息是否可判等我还没想好;均实现 `IMessage` 接口。 24 | 25 | - `Message` 类的其他修改 26 | 27 | 1. 要么把本地图片文件删掉(改用 Byte 数组图片),要么改名,以 `ImageLocal`/`ImageRemote` 区分? // `Image_Local`/`Image_Remote` 28 | 29 | - 优化 `Section` 类的序列化和反序列化过程。 30 | 31 | 3. 做完这些工作以后,考虑让 `ReceivedMessage` 直接反序列化。 32 | 33 | - `Section` 的 `Data` 改为 `NameValueCollection`。 34 | - 支持异步的事件处理器。 35 | - 将一些 Type 改为枚举。使用 `EnumMember` 特性标记。 36 | - 反序列化时传入静态的 setting 或者 serializer,避免潜在的全局 setting 影响。 37 | 38 | - 加入运行平台选项,发送本地图片时,如果检测到和酷 Q 运行在不同的机器上,可以尝试先读取,再以 base64 的方式发送,以便多机使用。 39 | 40 | - 把下面这种消息拼接方式抄过来 41 | ```C# 42 | // 戳一戳 43 | _mahuaApi.SendPrivateMessage("472158246") 44 | .Shake() 45 | .Done(); 46 | 47 | // 讨论组发送消息 48 | _mahuaApi.SendDiscussMessage("472158246") 49 | .Text("嘤嘤嘤:") 50 | .Newline() 51 | .Text("文章无聊,不如来局游戏http://www.newbe.pro") 52 | .Image(@"D:\logo.png") 53 | .Done(); 54 | 55 | // 群内at发送消息 56 | _mahuaApi.SendGroupMessage("610394020") 57 | .At("472158246") 58 | .Text("我想充钱") 59 | .Newline() 60 | .Done(); 61 | ``` 62 | 63 | - 当前转发时会统一转换成 UTF-8 编码。如果上报编码不是 UTF-8 会导致 `byte` 数组发生变化,从而导致 `X-Signature` 头部不正确。如有必要,在未来版本中修复。 64 | 65 | ## 已取消 66 | - 使得 `Post.AnonymousInfo` 可以比较。 67 | - 实现 `ReceivedMessage` 的判等。(请先判等 `RawMessage` 字段,再调用 `Content.Fix()` 并判等) -------------------------------------------------------------------------------- /Sisters.WudiLib/Utilities.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Sisters.WudiLib 4 | { 5 | internal static class Utilities 6 | { 7 | /// 8 | /// 检查是否为null或空值,并抛出相应的异常。 9 | /// 10 | /// 要检查的。 11 | /// TODO 12 | /// argument为空。 13 | /// argumentnull 14 | internal static void CheckStringArgument(string argument, string paramName) 15 | { 16 | if (argument is null) 17 | throw new ArgumentNullException(paramName); 18 | if (argument.Length == 0) 19 | throw new ArgumentException($"{paramName}为空。", paramName); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /Sisters.WudiLibTest/Program.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using System.Threading.Tasks; 4 | using Sisters.WudiLib; 5 | using Sisters.WudiLib.Posts; 6 | using Message = Sisters.WudiLib.SendingMessage; 7 | using MessageContext = Sisters.WudiLib.Posts.Message; 8 | 9 | namespace Sisters.WudiLibTest 10 | { 11 | internal static class Program 12 | { 13 | private static void PrintNoticeAndRequests(ApiPostListener apiPostListener) 14 | { 15 | void PrintPost(Post post) 16 | { 17 | Console.WriteLine(post.Time); 18 | Console.WriteLine("user: " + post.UserId); 19 | Console.WriteLine("self: " + post.SelfId); 20 | } 21 | 22 | void PrintRequest(Request request) 23 | { 24 | PrintPost(request); 25 | Console.WriteLine(request.Comment); 26 | Console.WriteLine(request.Flag); 27 | } 28 | 29 | apiPostListener.GroupFileUploadedEvent += (api, notice) => 30 | { 31 | Console.WriteLine("file uploaded"); 32 | PrintPost(notice); 33 | Console.WriteLine("group: " + notice.GroupId); 34 | Console.WriteLine($"file: {notice.File.Id} | name: {notice.File.Name} | size: {notice.File.Length} | busid: {notice.File.BusId}"); 35 | }; 36 | 37 | apiPostListener.GroupAdminSetEvent += (api, notice) => 38 | { 39 | Console.WriteLine($"admin set ({notice.SubType})"); 40 | PrintPost(notice); 41 | Console.WriteLine($"group: {notice.GroupId}"); 42 | }; 43 | 44 | apiPostListener.GroupAdminUnsetEvent += (api, notice) => 45 | { 46 | Console.WriteLine($"admin unset ({notice.SubType})"); 47 | PrintPost(notice); 48 | Console.WriteLine($"group: {notice.GroupId}"); 49 | }; 50 | 51 | apiPostListener.GroupMemberDecreasedEvent += (api, notice) => 52 | { 53 | Console.WriteLine($"group member decreased ({notice.SubType})"); 54 | PrintPost(notice); 55 | Console.WriteLine($"group: {notice.GroupId}"); 56 | Console.WriteLine($"operator: {notice.OperatorId}"); 57 | }; 58 | 59 | apiPostListener.KickedEvent += (api, notice) => 60 | { 61 | Console.WriteLine($"kicked ({notice.SubType})"); 62 | PrintPost(notice); 63 | Console.WriteLine($"group: {notice.GroupId}"); 64 | Console.WriteLine($"operator: {notice.OperatorId}"); 65 | }; 66 | 67 | apiPostListener.GroupMemberIncreasedEvent += (api, notice) => 68 | { 69 | Console.WriteLine($"group member increased ({notice.SubType})"); 70 | PrintPost(notice); 71 | Console.WriteLine($"group: {notice.GroupId}"); 72 | Console.WriteLine($"operator: {notice.OperatorId}"); 73 | }; 74 | 75 | apiPostListener.GroupAddedEvent += (api, notice) => 76 | { 77 | Console.WriteLine($"group join ({notice.SubType})"); 78 | PrintPost(notice); 79 | Console.WriteLine($"group: {notice.GroupId}"); 80 | Console.WriteLine($"operator: {notice.OperatorId}"); 81 | }; 82 | 83 | apiPostListener.FriendAddedEvent += (api, notice) => 84 | { 85 | Console.WriteLine("friend added"); 86 | PrintPost(notice); 87 | }; 88 | 89 | apiPostListener.FriendRequestEvent += (api, request) => 90 | { 91 | Console.WriteLine("friend request"); 92 | PrintRequest(request); 93 | return new FriendRequestResponse { Approve = false }; 94 | }; 95 | 96 | apiPostListener.GroupInviteEvent += (api, request) => 97 | { 98 | Console.WriteLine($"group invite"); 99 | PrintRequest(request); 100 | Console.WriteLine($"group: {request.GroupId}"); 101 | if (request.UserId != 962549599) 102 | { 103 | return new GroupRequestResponse { Approve = false }; 104 | } 105 | else 106 | { 107 | return new GroupRequestResponse { Approve = true }; 108 | } 109 | }; 110 | 111 | apiPostListener.GroupRequestEvent += (api, request) => 112 | { 113 | Console.WriteLine($"group request"); 114 | PrintRequest(request); 115 | Console.WriteLine($"group: {request.GroupId}"); 116 | return new GroupRequestResponse { Approve = false }; 117 | }; 118 | } 119 | 120 | private static void Main(string[] args) 121 | { 122 | var culture = CultureInfo.GetCultureInfo("zh-CN"); 123 | CultureInfo.DefaultThreadCurrentCulture = culture; 124 | CultureInfo.DefaultThreadCurrentUICulture = culture; 125 | CultureInfo.CurrentCulture = culture; 126 | CultureInfo.CurrentUICulture = culture; 127 | 128 | var httpApiClient = new HttpApiClient(); 129 | 130 | var friendList = httpApiClient.GetFriendListAsync().ConfigureAwait(false).GetAwaiter().GetResult(); 131 | // test for get_friend_list ha kokomade. 132 | 133 | var groupMemberInfo = httpApiClient.GetGroupMemberInfoAsync(661021255, 962549599).GetAwaiter().GetResult(); 134 | //var groupMemberInfos = httpApiClient.GetGroupMemberListAsync(641236878).GetAwaiter().GetResult(); 135 | 136 | var banResult = httpApiClient.BanAnonymousMember(72318078, "AAAAAAAPQl8ADMfHwK2hpMSqtvvDyQAoAHXUxZeiC+YKi480g3ERUrpzM+o20KsUJ0mm1xxoobOEtwYU+3KqiA==", 60).GetAwaiter().GetResult(); 137 | 138 | banResult = httpApiClient.BanWholeGroup(72318078, true).GetAwaiter().GetResult(); 139 | Task.Delay(TimeSpan.FromSeconds(10)).Wait(); 140 | banResult = httpApiClient.BanWholeGroup(72318078, false).GetAwaiter().GetResult(); 141 | 142 | var postListener = new ApiPostListener(8876); 143 | PrintNoticeAndRequests(postListener); 144 | postListener.StartListen(); 145 | Console.ReadKey(true); 146 | Environment.Exit(0); 147 | 148 | var httpApi = new HttpApiClient(); 149 | httpApi.ApiAddress = "http://127.0.0.1:5700/"; 150 | 151 | //var privateResponse = httpApi.SendPrivateMessageAsync(962549599, "hello").Result; 152 | //Console.WriteLine(privateResponse.MessageId); 153 | //var groupResponse = httpApi.SendGroupMessageAsync(72318078, "hello").Result; 154 | //Console.WriteLine(groupResponse.MessageId); 155 | //605617685 156 | #region kick test 157 | //var success1 = httpApi.KickGroupMember(605617685, 962549599); 158 | //var success2 = httpApi.KickGroupMember(72318078, 962549599); 159 | //Console.WriteLine(success1); 160 | //Console.WriteLine(success2); 161 | #endregion 162 | Console.WriteLine("--------------"); 163 | #region recall test 164 | //var delete1 = httpApi.RecallMessageAsync(privateResponse).Result; 165 | //var delete2 = httpApi.RecallMessageAsync(groupResponse).Result; 166 | //Console.WriteLine(delete1); 167 | //Console.WriteLine(delete2); 168 | #endregion 169 | 170 | #region group member info test 171 | //Console.Write("group num:"); 172 | //long group = long.Parse(Console.ReadLine().Trim()); 173 | //Console.Write("qq id:"); 174 | //long qqid = long.Parse(Console.ReadLine().Trim()); 175 | //var member = httpApi.GetGroupMemberInfoAsync(group, qqid).Result; 176 | //Console.WriteLine(member.Age); 177 | //Console.WriteLine(member.Area); 178 | //Console.WriteLine(member.Authority.ToString()); 179 | //Console.WriteLine(member.GroupId); 180 | //Console.WriteLine(member.InGroupName); 181 | //Console.WriteLine(member.IsCardChangeable); 182 | //Console.WriteLine(member.JoinTime); 183 | //Console.WriteLine(member.LastSendTime); 184 | //Console.WriteLine(member.Nickname); 185 | //Console.WriteLine(member.Title); 186 | //Console.WriteLine(member.UserId); 187 | 188 | //var memberList = httpApi.GetGroupMemberListAsync(605617685).Result; 189 | //var query = from m in memberList 190 | // where m.Age > 19 191 | // select new { m.InGroupName, m.Nickname, m.Area }; 192 | //foreach (var item in query) 193 | //{ 194 | // Console.WriteLine(item.InGroupName); 195 | // Console.WriteLine(item.Nickname); 196 | // Console.WriteLine(item.Area); 197 | //} 198 | #endregion 199 | 200 | #region Message Class Test 201 | //var message = new Message("this is at test,: "); 202 | //message += Message.At(962549599); 203 | //httpApi.SendGroupMessageAsync(72318078, message).Wait(); 204 | #endregion 205 | 206 | #region Image Test 207 | //var imgMessage = Message.LocalImage(@"C:\Users\Administrator\Desktop\Rinima.jpg"); 208 | //var netMessage = Message.NetImage(@"https://files.yande.re/image/ca815083c96a99a44ff72e70c6957c14/yande.re%20437737%20dennou_shoujo_youtuber_shiro%20heels%20pantyhose%20shiro_%28dennou_shoujo_youtuber_shiro%29%20shouju_ling.jpg"); 209 | //httpApi.SendGroupMessageAsync(72318078, imgMessage).Wait(); 210 | //httpApi.SendGroupMessageAsync(72318078, netMessage).Wait(); 211 | //httpApi.SendGroupMessageAsync(72318078, imgMessage + netMessage).Wait(); 212 | //message += netMessage; 213 | //httpApi.SendGroupMessageAsync(72318078, message).Wait(); 214 | #endregion 215 | 216 | RecordTestAsync(httpApi); 217 | 218 | //ListeningTest(httpApi); 219 | 220 | Console.WriteLine("end"); 221 | Console.ReadKey(); 222 | } 223 | 224 | private static async void RecordTestAsync(HttpApiClient httpApi) 225 | { 226 | var record = Message.NetRecord("https://b.ppy.sh/preview/758101.mp3"); 227 | await httpApi.SendPrivateMessageAsync(962549599, record); 228 | } 229 | 230 | private static void ListeningTest(HttpApiClient httpApi) 231 | { 232 | Console.WriteLine("input listening port"); 233 | string port = Console.ReadLine(); 234 | 235 | ApiPostListener listener = new ApiPostListener(); 236 | listener.ApiClient = httpApi; 237 | listener.PostAddress = $"http://127.0.0.1:{port}/"; 238 | listener.ForwardTo = "http://[::1]:10202"; 239 | listener.StartListen(); 240 | listener.FriendRequestEvent += Friend; 241 | listener.FriendRequestEvent += ApiPostListener.ApproveAllFriendRequests; 242 | listener.GroupInviteEvent += Group; 243 | listener.GroupInviteEvent += ApiPostListener.ApproveAllGroupRequests; 244 | listener.GroupRequestEvent += Group; 245 | listener.GroupRequestEvent += ApiPostListener.ApproveAllGroupRequests; 246 | //listener.MessageEvent += ApiPostListener.RepeatAsync; 247 | listener.MessageEvent += PrintRaw; 248 | listener.GroupNoticeEvent += ApiPostListener.RepeatAsync; 249 | listener.AnonymousMessageEvent += ApiPostListener.RepeatAsync; 250 | listener.AnonymousMessageEvent += PrintRaw; 251 | //listener.MessageEvent += ApiPostListener.Say("good"); 252 | 253 | var listener2 = new ApiPostListener(); 254 | listener2.PostAddress = "http://[::1]:10202"; 255 | listener2.StartListen(); 256 | listener2.MessageEvent += (api, message) => 257 | { 258 | Console.WriteLine(message.Time); 259 | Console.WriteLine(message.Content.Raw); 260 | }; 261 | } 262 | 263 | private static void PrintRaw(HttpApiClient api, MessageContext message) 264 | { 265 | Console.WriteLine(message.Content.Raw); 266 | } 267 | 268 | private static GroupRequestResponse Group(HttpApiClient api, GroupRequest request) 269 | { 270 | Console.WriteLine("Group"); 271 | Console.WriteLine(request.Time); 272 | Console.WriteLine(request.SelfId); 273 | Console.WriteLine(request.GroupId); 274 | Console.WriteLine(request.UserId); 275 | Console.WriteLine(request.Comment); 276 | Console.WriteLine(request.Flag); 277 | return null; 278 | } 279 | 280 | private static FriendRequestResponse Friend(HttpApiClient api, FriendRequest request) 281 | { 282 | Console.WriteLine("Friend"); 283 | Console.WriteLine(request.Time); 284 | Console.WriteLine(request.SelfId); 285 | Console.WriteLine(request.UserId); 286 | Console.WriteLine(request.Comment); 287 | Console.WriteLine(request.Flag); 288 | return null; 289 | } 290 | } 291 | } 292 | -------------------------------------------------------------------------------- /Sisters.WudiLibTest/Properties/PublishProfiles/FolderProfile.pubxml: -------------------------------------------------------------------------------- 1 |  2 | 6 | 7 | 8 | FileSystem 9 | Release 10 | netcoreapp2.0 11 | bin\Release\PublishOutput 12 | 13 | -------------------------------------------------------------------------------- /Sisters.WudiLibTest/Sisters.WudiLibTest.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | netcoreapp2.1 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | --------------------------------------------------------------------------------