diff --git a/.gitignore b/.gitignore index 947878d0..cc5f4f74 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ *.userprefs .buildvars.py .sconsign.dblite +.vs/config/* # Build results [Dd]ebug/ diff --git a/.travis.yml b/.travis.yml index c006d4df..a45fef66 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,56 +1,54 @@ -sudo: false -language: csharp - -compiler: - - clang - -cache: - apt: true - directories: - - $HOME/FFmpegbin - -addons: - apt: - sources: - # Try to add up to date compilers - - ubuntu-toolchain-r-test - - llvm-toolchain-precise-3.6 - packages: - - nasm - - build-essential - - cmake - - libcppunit-dev - - gcc-5 - - g++-5 - - clang-3.6 +language: generic +# Build with docker to have an up-to-date version of ubuntu +sudo: required +services: + - docker + +notifications: + email: false + +branches: + only: + - master + - develop + +git: + depth: 1 + +#cache: +# directories: +# - $HOME/docker install: - - nuget restore TS3AudioBot.sln - - nuget install NUnit.Runners -OutputDirectory nunit - -before_script: - - export THREADS=$((`nproc` + 1)) - - echo "THREADS = $THREADS" - - cd "$HOME" - - if [ ! -f "$HOME/FFmpegbin/libavcodec/libavcodec.so" ]; then git clone --depth 1 --branch n3.0 https://github.com/FFmpeg/FFmpeg.git; fi - - if [ ! -f "$HOME/FFmpegbin/libavcodec/libavcodec.so" ]; then mkdir -p FFmpegbin; cd FFmpegbin; fi - - if [ ! -f "$HOME/FFmpegbin/libavcodec/libavcodec.so" ]; then $HOME/FFmpeg/configure --enable-shared; fi - - if [ ! -f "$HOME/FFmpegbin/libavcodec/libavcodec.so" ]; then make -j $THREADS; fi - -solution: TS3AudioBot.sln + # Use the cached docker image if it is available + - mkdir -p "$HOME/docker" + - if [ -f "$HOME/docker/ts3audiobot.tar" ]; then + docker import "$HOME/docker/ts3audiobot.tar" ts3audiobot:latest; + else + docker build -t ts3audiobot -f Dockerfile .; + fi +# docker save -o "$HOME/docker/ts3audiobot.tar" ts3audiobot; +# fi + script: - # Building the AudioBob (C++ stuff) - - cd $TRAVIS_BUILD_DIR/TS3AudioBob - - mkdir bin - - cd bin - - cmake .. -DAVCODEC_INCLUDE_DIRS="$HOME/FFmpeg" -DAVCODEC_LIBRARIES="$HOME/FFmpegbin/libavcodec/libavcodec.so" -DAVFILTER_INCLUDE_DIRS="$HOME/FFmpeg" -DAVFILTER_LIBRARIES="$HOME/FFmpegbin/libavfilter/libavfilter.so" -DAVFORMAT_INCLUDE_DIRS="$HOME/FFmpeg" -DAVFORMAT_LIBRARIES="$HOME/FFmpegbin/libavformat/libavformat.so" -DAVUTIL_INCLUDE_DIRS="$HOME/FFmpegbin" -DAVUTIL_LIBRARIES="$HOME/FFmpegbin/libavutil/libavutil.so" -DSWSCALE_INCLUDE_DIRS="$HOME/FFmpeg" -DSWSCALE_LIBRARIES="$HOME/FFmpegbin/libswscale/libswscale.so" -DSWRESAMPLE_INCLUDE_DIRS="$HOME/FFmpeg" -DSWRESAMPLE_LIBRARIES="$HOME/FFmpegbin/libswresample/libswresample.so" -DSWSCALE_INCLUDE_DIRS="$HOME/FFmpeg" -DSWSCALE_LIBRARIES="$HOME/FFmpegbin/libswscale/libswscale.so" -DBUILD_TESTS=1 -DBUILD_FUZZ=1 -DCMAKE_C_COMPILER=`which clang` -DCMAKE_CXX_COMPILER=`which clang++` - - make VERBOSE=1 -j $THREADS - - LD_LIBRARY_PATH="$HOME/FFmpegbin/libavcodec:$HOME/FFmpegbin/libavfilter:$HOME/FFmpegbin/libavformat:$HOME/FFmpegbin/libavutil:$HOME/FFmpegbin/libswresample:$HOME/FFmpegbin/libswscale" ./ts3audiobobtest - - # Building the AudioBot (C# stuff) - - cd "$TRAVIS_BUILD_DIR" - - xbuild /p:Configuration=Release TS3AudioBot.sln - - mono ./nunit/NUnit.ConsoleRunner.*.*.*/tools/nunit3-console.exe ./TS3ABotUnitTests/bin/Release/TS3ABotUnitTests.dll + - docker run -v "$TRAVIS_BUILD_DIR:/build" ts3audiobot sh -c " + export THREADS=\$((`nproc` + 1)) && + echo \"THREADS = \$THREADS\" && + + echo Building the AudioBob \(C++ stuff\) && + cd TS3AudioBob && + mkdir bin && cd bin && + cmake .. -DBUILD_TESTS=1 -DBUILD_FUZZ=1 && + make VERBOSE=1 -j \$THREADS && + ./ts3audiobobtest && + cd ../.. && + + echo Building the AudioBot \(C# Stuff\) && + nuget restore TS3AudioBot.sln && + nuget install NUnit.Runners -OutputDirectory nunit && + xbuild /p:Configuration=Release TS3AudioBot.sln && + mono ./nunit/NUnit.ConsoleRunner.*.*.*/tools/nunit3-console.exe ./TS3ABotUnitTests/bin/Release/TS3ABotUnitTests.dll + " after_script: - cd $TRAVIS_BUILD_DIR diff --git a/ClassDiagram.dia b/ClassDiagram.dia index ecfe8bb2..2db56a53 100644 Binary files a/ClassDiagram.dia and b/ClassDiagram.dia differ diff --git a/ClassDiagram.svg b/ClassDiagram.svg index 53f48136..680e906c 100644 --- a/ClassDiagram.svg +++ b/ClassDiagram.svg @@ -1,377 +1,530 @@ - - - - - MainBot - - - - - - - - - AudioFramework - - - - - - - - - BobController - - - - - - - - - <<abstract>> - BotSession - - - - - - - - - ConfigFile - - - - - - - - - ErrorLogger - - - - + + + + + MainBot + + + + + + + + + AudioFramework + + + + + + + + + <<abstract>> + BotSession + + + + + + + + + ErrorLogger + + + + + + + + + <<interface>> + IPlayerConnection + + + + + + + SessionManager + + + + + + + + + + + + + + + + + + + + - - - HistoryManager - - - - + + - - - InfoAttribute - - - - + + + - - - <<interface>> - IPlayerConnection - - - - + + + AudioFrameworkData + + + + - - - QueryConnection - - - - + + - - - <<static>> - TextUtil - - - - + + + BobControllerData + + + + - - - Trie<T> - - - - + + - - - VLCConnection - - - - + + - - - YoutubeFramework - - - - + + - - - SessionManager - - - - + + + PrivateSession + + + + - - + + + PublicSession + + + + - - + + + - - + + + - - + + + MainBotData + + + + - - + + - - + + - - + + + QueryConnectionData + + + + - - - + + - - - <<abstract>> - AudioRessource - - - - + + - - - MediaRessource - - - - + + + + + + - - - + + + + + Algorithm - - - AudioFrameworkData - - - - + + + <<interface>> + IShuffleAlgorithm + + - - + + + ListedShuffle + + + + - - - BobControllerData - - - - + + + - - + + + <<interfacer>> + ISubstringSearch + + - - + + + - - + + + SimpleSubstringFinder + + + + - - - PrivateSession - - - - + + + + + Helper - - - PublicSession - - - - + + + <<static>> + TextUtil + + + + - - - + + + <<static>> + Util + + + + - - - + + + + + ResourceFactories - - + + + ResourceFactoryManager + + + + - - - MainBotData - - - - + + + <<interface>> + IResourceFactory + + - - + + - - - BotCommand - - - - + + + MediaFactory + + + + - - + + + SoundcloudFactory + + + + - - + + + TwitchFactory + + + + - - + + + YoutubeFactory + + + + - - - <<static>> - Util - - - - + + + - - - QueryConnectionData - - - - + + + - - + + + - - - VideoType - - - - + + + - - + + - - - YoutubeRessource - - - - + + - - - + + - - - - - Teamspeak3QueryLibrary - + + - - + + - - - - - Teamspeak3ClientPulgin - + + + PlaylistManager + + + + - - + + + + + + + PluginManager + + + + + + + + + PlayManager + + + + + + + + + + + + + + + + + + HistoryManager + + + + + + + + + + + HistorySystem + + + + + + + + XCommandSystem + + + + + + + + + + + CommandSystem + + + + + + + + QueryConnection + + + + + + + + + + + Teamspeak3QuerySystem + + + + + + + + BobController + + + + + + + + + + + Teamspeak3Client+Pulgin + + + + + + + + ConfigFile + + + + + + + + + InfoAttribute + + + + + + + + + + AudioResource + + + + + + + + + + + + + PlayData + + + + + + + + - - - - diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..e3ca9211 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,9 @@ +FROM ubuntu:xenial + +# Get dependencies +RUN apt-get update && apt-get -y install build-essential cmake libcppunit-dev \ + libavcodec-dev libavfilter-dev libavformat-dev libavresample-dev \ + libavutil-dev git mono-xbuild nuget mono-devel + +RUN mkdir /build +WORKDIR /build diff --git a/PluginTests/App.config b/PluginTests/App.config new file mode 100644 index 00000000..8e156463 --- /dev/null +++ b/PluginTests/App.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/PluginTests/PluginA.cs b/PluginTests/PluginA.cs new file mode 100644 index 00000000..a603ec14 --- /dev/null +++ b/PluginTests/PluginA.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace PluginTests +{ + class PluginA : IPlugin + { + public void Initialize(MainClass mc) + { + + } + } +} diff --git a/PluginTests/PluginTests.csproj b/PluginTests/PluginTests.csproj new file mode 100644 index 00000000..b7d7cabe --- /dev/null +++ b/PluginTests/PluginTests.csproj @@ -0,0 +1,62 @@ + + + + + Debug + AnyCPU + {DDBB1943-929D-43F6-A7E1-01903DD05D64} + Exe + Properties + PluginTests + PluginTests + v4.5 + 512 + + + AnyCPU + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + AnyCPU + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + + + + + + + + PreserveNewest + + + + + + + + + + \ No newline at end of file diff --git a/PluginTests/Program.cs b/PluginTests/Program.cs new file mode 100644 index 00000000..5a4b37a6 --- /dev/null +++ b/PluginTests/Program.cs @@ -0,0 +1,135 @@ +using System; +using System.CodeDom.Compiler; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Linq; + +namespace PluginTests +{ + class Program + { + static void Main(string[] args) + { + var mc = new MainClass(); + mc.LoadPlugin(new FileInfo("PluginA.cs")); + } + } + + public class MainClass + { + public void Feature1() { } + public void Feature2(int value) { int x = value + 10; var x2 = x.ToString().Split('1'); } + public int Feature3() { return 42; } + public MainClass Feature4() { return this; } + + private static readonly FileInfo ts3File = new FileInfo(typeof(IPlugin).Assembly.Location); + public AppDomain domain; + + public PluginResponse LoadPlugin(FileInfo file) + { + try + { + if (file.Extension != ".cs" && file.Extension != ".dll" && file.Extension != ".exe") + return PluginResponse.UnsupportedFile; + + domain = AppDomain.CreateDomain( + "Plugin_" + file.Name, + AppDomain.CurrentDomain.Evidence, + new AppDomainSetup + { + ShadowCopyFiles = "true", + ShadowCopyDirectories = ts3File.Directory.FullName, + ApplicationBase = ts3File.Directory.FullName, + PrivateBinPath = "Plugin/..;Plugin", + PrivateBinPathProbe = "" + }); + domain.UnhandledException += (s, e) => { Console.WriteLine("Plugin unex: {0}", e.ExceptionObject); }; + + //domain.Load() + + Assembly result; + if (file.Extension == ".cs") + result = PrepareSource(file); + else if (file.Extension == ".dll" || file.Extension == ".exe") + result = PrepareBinary(file); + else throw new InvalidProgramException(); + + return PluginResponse.Ok; + } + catch (Exception ex) + { + Console.WriteLine("Possible plugin failed to load: ", ex); + return PluginResponse.Crash; + } + } + + private static CompilerParameters GenerateCompilerParameter() + { + var cp = new CompilerParameters(); + Assembly[] aarr = AppDomain.CurrentDomain.GetAssemblies(); + for (int i = 0; i < aarr.Length; i++) + { + if (aarr[i].IsDynamic) continue; + cp.ReferencedAssemblies.Add(aarr[i].Location); + } + cp.ReferencedAssemblies.Add(Assembly.GetExecutingAssembly().Location); + + // set preferences + cp.WarningLevel = 3; + cp.CompilerOptions = "/target:library /optimize"; + cp.GenerateExecutable = false; + cp.GenerateInMemory = true; + return cp; + } + + private Assembly PrepareSource(FileInfo file) + { + var provider = CodeDomProvider.CreateProvider("CSharp"); + var cp = GenerateCompilerParameter(); + var result = provider.CompileAssemblyFromFile(cp, file.FullName); + + if (result.Errors.Count > 0) + { + bool containsErrors = false; + foreach (CompilerError error in result.Errors) + { + containsErrors |= !error.IsWarning; + Console.WriteLine("Plugin_{0}: {1} L{2}/C{3}: {4}\n", + file.Name, + error.IsWarning ? "Warning" : "Error", + error.Line, + error.Column, + error.ErrorText); + } + + if (containsErrors) + return null; + } + + return result.CompiledAssembly; + } + + private Assembly PrepareBinary(FileInfo file) + { + return null; + } + } + + public interface IPlugin + { + void Initialize(MainClass mc); + } + + public enum PluginResponse + { + Ok, + UnsupportedFile, + Crash, + NoTypeMatch, + TooManyPlugins, + UnknownStatus, + PluginNotFound, + CompileError, + } +} diff --git a/PluginTests/Properties/AssemblyInfo.cs b/PluginTests/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..58bdfa84 --- /dev/null +++ b/PluginTests/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("PluginTests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("PluginTests")] +[assembly: AssemblyCopyright("Copyright © 2016")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("ddbb1943-929d-43f6-a7e1-01903dd05d64")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/TS3ABotUnitTests/BotCommandTests.cs b/TS3ABotUnitTests/BotCommandTests.cs index a8137f08..65c2caa5 100644 --- a/TS3ABotUnitTests/BotCommandTests.cs +++ b/TS3ABotUnitTests/BotCommandTests.cs @@ -19,11 +19,11 @@ namespace TS3ABotUnitTests using System; using NUnit.Framework; - using TS3Query.Messages; + using TS3Client.Messages; using TS3AudioBot; using TS3AudioBot.CommandSystem; - using TS3Query; + using TS3Client; [TestFixture] public class BotCommandTests @@ -50,7 +50,7 @@ TextMessage CreateTextMessage() string CallCommand(string command) { - var info = new ExecutionInformation(null, CreateTextMessage(), new Lazy(true)); + var info = new ExecutionInformation(null, CreateTextMessage(), new Lazy(() => true)); return bot.CommandManager.CommandSystem.ExecuteCommand(info, command); } diff --git a/TS3ABotUnitTests/TS3ABotUnitTests.csproj b/TS3ABotUnitTests/TS3ABotUnitTests.csproj index fa5b3d71..4487958a 100644 --- a/TS3ABotUnitTests/TS3ABotUnitTests.csproj +++ b/TS3ABotUnitTests/TS3ABotUnitTests.csproj @@ -1,4 +1,4 @@ - + Debug @@ -87,6 +87,10 @@ {0ecc38f3-de6e-4d7f-81eb-58b15f584635} TS3AudioBot + + {0eb99e9d-87e5-4534-a100-55d231c2b6a6} + TS3Client + diff --git a/TS3ABotUnitTests/UnitTests.cs b/TS3ABotUnitTests/UnitTests.cs index 79a26e6b..65842bf4 100644 --- a/TS3ABotUnitTests/UnitTests.cs +++ b/TS3ABotUnitTests/UnitTests.cs @@ -17,20 +17,19 @@ namespace TS3ABotUnitTests { using System; + using System.Collections; using System.Collections.Generic; using System.IO; using System.Linq; using LockCheck; using NUnit.Framework; - using TS3AudioBot; using TS3AudioBot.Algorithm; + using TS3AudioBot.CommandSystem; using TS3AudioBot.Helper; using TS3AudioBot.History; using TS3AudioBot.ResourceFactories; - using TS3AudioBot.CommandSystem; - - using TS3Query.Messages; + using TS3Client.Messages; [TestFixture] public class UnitTests @@ -56,11 +55,13 @@ public void HistoryFileIntergrityTest() var inv2 = Generator.ActivateResponse(); { inv2.ClientId = 20; inv2.DatabaseId = 102; inv2.NickName = "Invoker2"; } - var ar1 = new SoundcloudResource("asdf", "sc_ar1", "https://soundcloud.de/sc_ar1"); - var ar2 = new MediaResource("./File.mp3", "me_ar2", "https://splamy.de/sc_ar2", RResultCode.Success); + var ar1 = new AudioResource("asdf", "sc_ar1", AudioType.Soundcloud); + var ar2 = new AudioResource("./File.mp3", "me_ar2", AudioType.MediaLink); + var ar3 = new AudioResource("kitty", "tw_ar3", AudioType.Twitch); - var data1 = new PlayData(null, inv1, "", false) { Resource = ar1, }; - var data2 = new PlayData(null, inv2, "", false) { Resource = ar2, }; + var data1 = new HistorySaveData(ar1, inv1.DatabaseId); + var data2 = new HistorySaveData(ar2, inv2.DatabaseId); + var data3 = new HistorySaveData(ar3, 103); HistoryFile hf = new HistoryFile(); @@ -71,7 +72,7 @@ public void HistoryFileIntergrityTest() var lastXEntries = hf.GetLastXEntrys(1); Assert.True(lastXEntries.Any()); var lastEntry = lastXEntries.First(); - Assert.AreEqual(ar1, lastEntry); + Assert.AreEqual(ar1, lastEntry.AudioResource); hf.CloseFile(); @@ -79,7 +80,7 @@ public void HistoryFileIntergrityTest() lastXEntries = hf.GetLastXEntrys(1); Assert.True(lastXEntries.Any()); lastEntry = lastXEntries.First(); - Assert.AreEqual(ar1, lastEntry); + Assert.AreEqual(ar1, lastEntry.AudioResource); hf.Store(data1); hf.Store(data2); @@ -87,7 +88,7 @@ public void HistoryFileIntergrityTest() lastXEntries = hf.GetLastXEntrys(1); Assert.True(lastXEntries.Any()); lastEntry = lastXEntries.First(); - Assert.AreEqual(ar2, lastEntry); + Assert.AreEqual(ar2, lastEntry.AudioResource); hf.CloseFile(); @@ -95,8 +96,8 @@ public void HistoryFileIntergrityTest() hf.OpenFile(testFile); var lastXEntriesArray = hf.GetLastXEntrys(2).ToArray(); Assert.AreEqual(2, lastXEntriesArray.Length); - Assert.AreEqual(ar1, lastXEntriesArray[0]); - Assert.AreEqual(ar2, lastXEntriesArray[1]); + Assert.AreEqual(ar1, lastXEntriesArray[0].AudioResource); + Assert.AreEqual(ar2, lastXEntriesArray[1].AudioResource); var ale1 = hf.GetEntryById(hf.Contains(ar1).Value); hf.LogEntryRename(ale1, "sc_ar1X", false); @@ -109,8 +110,8 @@ public void HistoryFileIntergrityTest() hf.OpenFile(testFile); lastXEntriesArray = hf.GetLastXEntrys(2).ToArray(); Assert.AreEqual(2, lastXEntriesArray.Length); - Assert.AreEqual(ar2, lastXEntriesArray[0]); - Assert.AreEqual(ar1, lastXEntriesArray[1]); + Assert.AreEqual(ar2, lastXEntriesArray[0].AudioResource); + Assert.AreEqual(ar1, lastXEntriesArray[1].AudioResource); var ale2 = hf.GetEntryById(hf.Contains(ar2).Value); hf.LogEntryRename(ale2, "me_ar2_loong1"); @@ -125,31 +126,43 @@ public void HistoryFileIntergrityTest() hf.CloseFile(); - // reckeck order + // recheck order hf.OpenFile(testFile); lastXEntriesArray = hf.GetLastXEntrys(2).ToArray(); Assert.AreEqual(2, lastXEntriesArray.Length); - Assert.AreEqual(ar1, lastXEntriesArray[0]); - Assert.AreEqual(ar2, lastXEntriesArray[1]); + Assert.AreEqual(ar1, lastXEntriesArray[0].AudioResource); + Assert.AreEqual(ar2, lastXEntriesArray[1].AudioResource); hf.CloseFile(); - // delete entry 2 + // delete entry 1 hf.OpenFile(testFile); - hf.LogEntryRemove(hf.GetEntryById(hf.Contains(ar2).Value)); + hf.LogEntryRemove(hf.GetEntryById(hf.Contains(ar1).Value)); - lastXEntriesArray = hf.GetLastXEntrys(2).ToArray(); + lastXEntriesArray = hf.GetLastXEntrys(3).ToArray(); Assert.AreEqual(1, lastXEntriesArray.Length); - Assert.AreEqual(ar1, lastXEntriesArray[0]); + + // .. store new entry to check correct stream position writes + hf.Store(data3); + + lastXEntriesArray = hf.GetLastXEntrys(3).ToArray(); + Assert.AreEqual(2, lastXEntriesArray.Length); hf.CloseFile(); - // delete entry 1 + // delete entry 2 hf.OpenFile(testFile); - hf.LogEntryRemove(hf.GetEntryById(hf.Contains(ar1).Value)); + // .. check integrity from previous store + lastXEntriesArray = hf.GetLastXEntrys(3).ToArray(); + Assert.AreEqual(2, lastXEntriesArray.Length); - lastXEntriesArray = hf.GetLastXEntrys(2).ToArray(); - Assert.AreEqual(0, lastXEntriesArray.Length); + // .. delete and recheck + hf.LogEntryRemove(hf.GetEntryById(hf.Contains(ar2).Value)); + + lastXEntriesArray = hf.GetLastXEntrys(3).ToArray(); + Assert.AreEqual(1, lastXEntriesArray.Length); + Assert.AreEqual(ar3, lastXEntriesArray[0].AudioResource); hf.CloseFile(); + File.Delete(testFile); } @@ -269,8 +282,8 @@ public void XCommandSystemFilterTest() // Ambiguous command result = XCommandSystem.FilterList(filterList, "p"); Assert.AreEqual(2, result.Count()); - Assert.IsTrue(result.Where(r => r.Key == "ply").Any()); - Assert.IsTrue(result.Where(r => r.Key == "pla").Any()); + Assert.IsTrue(result.Any(r => r.Key == "ply")); + Assert.IsTrue(result.Any(r => r.Key == "pla")); } [Test] @@ -322,7 +335,7 @@ public void SimpleSubstringFinderTest() TestISubstringFinder(subf); } - public void TestISubstringFinder(ISubstringSearch subf) + private static void TestISubstringFinder(ISubstringSearch subf) { subf.Add("thisIsASongName", "1"); subf.Add("abcdefghijklmnopqrstuvwxyz", "2"); @@ -346,14 +359,46 @@ public void TestISubstringFinder(ISubstringSearch subf) Assert.True(HaveSameItems(res, new string[0])); } - static bool HaveSameItems(IEnumerable self, IEnumerable other) => !other.Except(self).Any() && !self.Except(other).Any(); + private static bool HaveSameItems(IEnumerable self, IEnumerable other) => !other.Except(self).Any() && !self.Except(other).Any(); + + [Test] + public void ListedShuffleTest() + { + TestShuffleAlgorithm(new ListedShuffle()); + } + + [Test] + public void LinearFeedbackShiftRegisterTest() + { + TestShuffleAlgorithm(new LinearFeedbackShiftRegister()); + } + + private static void TestShuffleAlgorithm(IShuffleAlgorithm algo) + { + for (int i = 1; i < 1000; i++) + { + BitArray checkNumbers = new BitArray(i, false); + + algo.Length = i; + algo.Seed = i; + + for (int j = 0; j < i; j++) + { + algo.Next(); + int shufNum = algo.Index; + if (checkNumbers.Get(shufNum)) + Assert.Fail("Duplicate number"); + checkNumbers.Set(shufNum, true); + } + } + } /* =================== ResourceFactories Tests =====================*/ [Test] public void Factory_YoutubeFactoryTest() { - using (IResourceFactory rfac = new YoutubeFactory()) + using (IResourceFactory rfac = new YoutubeFactory(new YoutubeFactoryData())) { // matching links Assert.True(rfac.MatchLink(@"https://www.youtube.com/watch?v=robqdGEhQWo")); diff --git a/TS3AudioBob/src/ServerConnection.cpp b/TS3AudioBob/src/ServerConnection.cpp index c732a81f..7b0044ff 100644 --- a/TS3AudioBob/src/ServerConnection.cpp +++ b/TS3AudioBob/src/ServerConnection.cpp @@ -368,7 +368,7 @@ std::string ServerConnection::getAudioStatus() const else { if (audioPlayer->getReadError() != audio::Player::READ_ERROR_NONE) - out << "error\nread error " << audio::Player::getReadErrorDescription( + out << "error\nread error " << *audio::Player::getReadErrorDescription( audioPlayer->getReadError()); else if (audioPlayer->isFinished()) out << "finished"; @@ -378,7 +378,7 @@ std::string ServerConnection::getAudioStatus() const out << "playing"; if (audioPlayer->getDecodeError() != audio::Player::DECODE_ERROR_NONE) - out << "\ndecode error " << audio::Player::getDecodeErrorDescription( + out << "\ndecode error " << *audio::Player::getDecodeErrorDescription( audioPlayer->getDecodeError()); if (audioPlayer->getReadError() == audio::Player::READ_ERROR_NONE) diff --git a/TS3AudioBot.sln b/TS3AudioBot.sln index 74d620d7..615aa091 100644 --- a/TS3AudioBot.sln +++ b/TS3AudioBot.sln @@ -1,12 +1,15 @@ - Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 2013 -VisualStudioVersion = 12.0.31101.0 +# Visual Studio 15 +VisualStudioVersion = 15.0.25618.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TS3AudioBot", "TS3AudioBot\TS3AudioBot.csproj", "{0ECC38F3-DE6E-4D7F-81EB-58B15F584635}" EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TS3ABotUnitTests", "TS3ABotUnitTests\TS3ABotUnitTests.csproj", "{20B6F767-5396-41D9-83D8-98B5730C6E2E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PluginTests", "PluginTests\PluginTests.csproj", "{DDBB1943-929D-43F6-A7E1-01903DD05D64}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "TS3Client", "TS3Client\TS3Client.csproj", "{0EB99E9D-87E5-4534-A100-55D231C2B6A6}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +24,14 @@ Global {20B6F767-5396-41D9-83D8-98B5730C6E2E}.Debug|Any CPU.Build.0 = Debug|Any CPU {20B6F767-5396-41D9-83D8-98B5730C6E2E}.Release|Any CPU.ActiveCfg = Release|Any CPU {20B6F767-5396-41D9-83D8-98B5730C6E2E}.Release|Any CPU.Build.0 = Release|Any CPU + {DDBB1943-929D-43F6-A7E1-01903DD05D64}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {DDBB1943-929D-43F6-A7E1-01903DD05D64}.Debug|Any CPU.Build.0 = Debug|Any CPU + {DDBB1943-929D-43F6-A7E1-01903DD05D64}.Release|Any CPU.ActiveCfg = Release|Any CPU + {DDBB1943-929D-43F6-A7E1-01903DD05D64}.Release|Any CPU.Build.0 = Release|Any CPU + {0EB99E9D-87E5-4534-A100-55D231C2B6A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0EB99E9D-87E5-4534-A100-55D231C2B6A6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0EB99E9D-87E5-4534-A100-55D231C2B6A6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0EB99E9D-87E5-4534-A100-55D231C2B6A6}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/TS3AudioBot/Algorithm/IShuffleAlgorithm.cs b/TS3AudioBot/Algorithm/IShuffleAlgorithm.cs index 7c550793..ad77972f 100644 --- a/TS3AudioBot/Algorithm/IShuffleAlgorithm.cs +++ b/TS3AudioBot/Algorithm/IShuffleAlgorithm.cs @@ -18,9 +18,19 @@ namespace TS3AudioBot.Algorithm { public interface IShuffleAlgorithm { - void SetData(int length); - void SetData(int seed, int length); - - int SeedIndex(int i); + int Seed { get; set; } + int Length { get; set; } + int Index { get; set; } + void Next(); + void Prev(); } + + // Output conventions: + // + // if Index = x, x >= Length + // => Index = Util.MathMod(Index, Length) + // if Index = x, x < 0 + // => Index : undefined + // if Index = x, Length < 0 + // => Index = -1 } diff --git a/TS3AudioBot/Algorithm/ISubstringSearch.cs b/TS3AudioBot/Algorithm/ISubstringSearch.cs index 55757f7b..7cd08d96 100644 --- a/TS3AudioBot/Algorithm/ISubstringSearch.cs +++ b/TS3AudioBot/Algorithm/ISubstringSearch.cs @@ -21,7 +21,8 @@ namespace TS3AudioBot.Algorithm public interface ISubstringSearch { void Add(string key, T value); - void Remove(string key); + void RemoveKey(string key); + void RemoveValue(T value); IList GetValues(string key); void Clear(); } diff --git a/TS3AudioBot/Algorithm/LinearFeedbackShiftRegister.cs b/TS3AudioBot/Algorithm/LinearFeedbackShiftRegister.cs new file mode 100644 index 00000000..9d980abd --- /dev/null +++ b/TS3AudioBot/Algorithm/LinearFeedbackShiftRegister.cs @@ -0,0 +1,125 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3AudioBot.Algorithm +{ + using System; + using Helper; + + public class LinearFeedbackShiftRegister : IShuffleAlgorithm + { + private int register = 1; // aka index + private int mask = 0; + private int seed; + private int length; + private bool needsRefresh = true; + + public int Seed { get { return seed; } set { needsRefresh = true; seed = value; } } + public int Length { get { return length; } set { needsRefresh = true; length = value; } } + public int Index + { + get { if (Length <= 0) return -1; return Util.MathMod(register + Seed, Length); } + set { if (Length <= 0) return; Recalc(); register = Util.MathMod(value - Seed, Length); } + } + + private void Recalc() + { + if (!needsRefresh) return; + needsRefresh = false; + + if (Length <= 0) return; + register = (register % Length) + 1; + + // get the highest set bit (+1) to hold at least all values with a power of 2 + int maxPow = 31; + while (((1 << maxPow) & Length) == 0 && maxPow >= 0) maxPow--; + mask = GenerateGaloisMask(maxPow + 1, seed); + } + + public void Next() + { + if (Length <= 0) return; + Recalc(); + do + { + register = NextOf(register); + } while ((uint)register > Length); + } + + public void Prev() + { + if (Length <= 0) return; + Recalc(); + for (int i = 0; i < Length; i++) + if (NextOf(i) == register) + { + register = i; + return; + } + throw new InvalidOperationException(); + } + + private int NextOf(int val) + { + var lsb = val & 1; + val >>= 1; + val ^= -lsb & mask; + return val; + } + + private static int GenerateGaloisMask(int bits, int seedOffset) + { + if (bits == 1) return 1; + if (bits == 2) return 3; + + int start = 1 << (bits - 1); + int end = 1 << (bits); + int diff = end - start; + + for (int i = 0; i < diff; i++) + { + int checkMask = Util.MathMod(i + seedOffset, diff) + start; + if (NumberOfSetBits(checkMask) % 2 != 0) continue; + + if (TestLFSR(checkMask, end)) + return checkMask; + } + throw new InvalidOperationException(); + } + + private static bool TestLFSR(int mask, int max) + { + const int start = 1; + int field = start; + + for (int i = 2; i < max; i++) + { + int lsb = field & 1; + field >>= 1; + field ^= -lsb & mask; + if (field == start) return false; + } + return true; + } + + private static int NumberOfSetBits(int i) + { + i = i - ((i >> 1) & 0x55555555); + i = (i & 0x33333333) + ((i >> 2) & 0x33333333); + return (((i + (i >> 4)) & 0x0F0F0F0F) * 0x01010101) >> 24; + } + } +} diff --git a/TS3AudioBot/Algorithm/ListedShuffle.cs b/TS3AudioBot/Algorithm/ListedShuffle.cs index 6035a8a7..cbf78a88 100644 --- a/TS3AudioBot/Algorithm/ListedShuffle.cs +++ b/TS3AudioBot/Algorithm/ListedShuffle.cs @@ -18,36 +18,56 @@ namespace TS3AudioBot.Algorithm { using System; using System.Linq; + using Helper; - class ListedShuffle : IShuffleAlgorithm + public class ListedShuffle : IShuffleAlgorithm { private int[] permutation; + private bool needsRecalc = true; + private int index = 0; private int seed = 0; private int length = 0; - public int Seed => seed; - - public void SetData(int length) + public int Seed { - Random rngeesus = new Random(); - SetData(rngeesus.Next(), length); + get { return seed; } + set { needsRecalc = true; seed = value; } } - public void SetData(int seed, int length) + public int Length { - this.seed = seed; - this.length = length; - - if (length != 0) + get { return length; } + set { needsRecalc = true; length = value; } + } + public int Index + { + get + { + if (Length <= 0) return -1; GenList(); + return permutation[index]; + } + set + { + if (Length <= 0) return; + GenList(); + index = Array.IndexOf(permutation, Util.MathMod(value, permutation.Length)); + } } private void GenList() { + if (!needsRecalc) return; + needsRecalc = false; + + if (Length <= 0) return; + Random rngeesus = new Random(seed); permutation = Enumerable.Range(0, length).Select(i => i).OrderBy(x => rngeesus.Next()).ToArray(); + index %= Length; } - public int SeedIndex(int i) => permutation[i % permutation.Length]; + public void Next() { if (Length <= 0) return; GenList(); index = (index + 1) % permutation.Length; } + public void Prev() { if (Length <= 0) return; GenList(); index = (index - 1) % permutation.Length; } } } diff --git a/TS3AudioBot/Algorithm/SimpleSubstringFinder.cs b/TS3AudioBot/Algorithm/SimpleSubstringFinder.cs index c33d2d06..bb2a86a8 100644 --- a/TS3AudioBot/Algorithm/SimpleSubstringFinder.cs +++ b/TS3AudioBot/Algorithm/SimpleSubstringFinder.cs @@ -18,68 +18,43 @@ namespace TS3AudioBot.Algorithm { using System; using System.Collections.Generic; + using Helper; + using System.Linq; public class SimpleSubstringFinder : ISubstringSearch { - private List keys; - private List values; - private HashSet keyHash; + private Dictionary values; public SimpleSubstringFinder() { - keys = new List(); - values = new List(); - keyHash = new HashSet(); + Util.Init(ref values); } public void Add(string key, T value) { - if (!keyHash.Contains(key)) - { - keys.Add(key); - keyHash.Add(key); - values.Add(value); - } + if (!values.ContainsKey(key)) + values.Add(key, value); } - public void Remove(string key) + public void RemoveKey(string key) { - for (int i = 0; i < keys.Count; i++) - { - if (keys[i] == key) - { - RemoveIndex(i); - return; - } - } + if (!values.ContainsKey(key)) + values.Remove(key); } - private void RemoveIndex(int i) + public void RemoveValue(T value) { - string value = keys[i]; - keys.RemoveAt(i); - values.RemoveAt(i); - keyHash.Remove(value); + var arr = values.Where(v => v.Value.Equals(value)).ToArray(); + for (int i = 0; i < arr.Length; i++) + values.Remove(arr[i].Key); } public IList GetValues(string key) - { - var result = new List(); - for (int i = 0; i < keys.Count; i++) - { - if (keys[i].IndexOf(key, StringComparison.OrdinalIgnoreCase) >= 0) - { - result.Add(values[i]); - } - } - return result; - } + => values.Where(v => v.Key.IndexOf(key, StringComparison.OrdinalIgnoreCase) >= 0).Select(v => v.Value).ToList(); public void Clear() { - keys.Clear(); values.Clear(); - keyHash.Clear(); } } } diff --git a/TS3AudioBot/AudioFramework.cs b/TS3AudioBot/AudioFramework.cs index e9cd66c7..28072d25 100644 --- a/TS3AudioBot/AudioFramework.cs +++ b/TS3AudioBot/AudioFramework.cs @@ -17,182 +17,154 @@ namespace TS3AudioBot { using System; - using TS3AudioBot.Helper; + using Helper; + using ResourceFactories; - public sealed class AudioFramework : MarshalByRefObject, IDisposable + public sealed class AudioFramework : IDisposable { public int MaxUserVolume => audioFrameworkData.maxUserVolume; public const int MaxVolume = 100; - private static readonly TimeSpan SongEndTimeout = TimeSpan.FromSeconds(30); - private static readonly TimeSpan SongEndTimeoutInterval = TimeSpan.FromSeconds(1); private AudioFrameworkData audioFrameworkData; - private TickWorker waitEndTick; - private DateTime endTime; - public PlayData CurrentPlayData { get; private set; } private IPlayerConnection playerConnection; - public PlaylistManager PlaylistManager { get; } - public event EventHandler OnResourceStarted; - public event EventHandler OnResourceStopped; + internal event EventHandler OnResourceStopped; // Playerproperties - public bool IsPlaying => CurrentPlayData != null; - /// Loop state for the entire playlist. - public bool Loop { get { return PlaylistManager.Loop; } set { PlaylistManager.Loop = value; } } /// Loop state for the current song. - public bool Repeat { get { return playerConnection.Repeated; } set { playerConnection.Repeated = value; } } - public int Volume { get { return playerConnection.Volume; } set { playerConnection.Volume = value; } } - /// Starts or resumes the current song. - public bool Pause { get { return playerConnection.Pause; } set { playerConnection.Pause = value; } } - - // Playermethods - - /// Jumps to the position in the audiostream if available. - /// Position in seconds from the start. - /// True if the seek request was valid, false otherwise. - public bool Seek(TimeSpan pos) + public bool Repeat { - if (pos < TimeSpan.Zero || pos > playerConnection.Length) + get + { + var result = playerConnection.IsRepeated(); + if (result) return result.Value; + Log.Write(Log.Level.Error, "Broken playerConnection request! (Repeat)"); return false; - playerConnection.Position = pos; - return true; + } + set { playerConnection.SetRepeated(value); } } - - /// Plays the next song in the playlist or queue. - public void Next() + /// Gets or sets the volume for the current song. + /// Value between 0 and MaxVolume. 40 Is usually pretty loud already :). + public int Volume { - var next = PlaylistManager.Next(); - if (next != null) + get { - CurrentPlayData = next; - StartResource(CurrentPlayData); + var result = playerConnection.GetVolume(); + if (result) return result.Value; + Log.Write(Log.Level.Error, "Broken playerConnection request! (Volume)"); + return 0; } - else + set { - Stop(false); + if (value < 0 || value > MaxVolume) + throw new ArgumentOutOfRangeException(nameof(value)); + playerConnection.SetVolume(value); } } - - /// Plays the previous song in the playlist. - public void Previous() + /// Starts or resumes the current song. + public bool Pause { - // TODO via history ? + get + { + var result = playerConnection.IsPaused(); + if (result) return result.Value; + Log.Write(Log.Level.Error, "Broken playerConnection request! (Pause)"); + return false; + } + set { playerConnection.SetPaused(value); } + } + /// Length of the current song. + public TimeSpan Length + { + get + { + var result = playerConnection.GetLength(); + if (result) return result.Value; + Log.Write(Log.Level.Error, "Broken playerConnection request! (Length)"); + return TimeSpan.Zero; + } + } + /// Gets or sets the play position of the current song. + public TimeSpan Position + { + get + { + var result = playerConnection.GetPosition(); + if (result) return result.Value; + Log.Write(Log.Level.Error, "Broken playerConnection request! (Position)"); + return TimeSpan.Zero; + } + set + { + if (value < TimeSpan.Zero || value > Length) + throw new ArgumentOutOfRangeException(nameof(value)); + playerConnection.SetPosition(value); + } } - - /// Clears the current playlist - public void Clear() => PlaylistManager.Clear(); - - private void OnSongEnd() => Next(); // Audioframework /// Creates a new AudioFramework /// Required initialization data from a ConfigFile interpreter. - internal AudioFramework(AudioFrameworkData afd, IPlayerConnection audioBackEnd, PlaylistManager playlistMgr) + public AudioFramework(AudioFrameworkData afd, IPlayerConnection audioBackEnd) { if (audioBackEnd == null) throw new ArgumentNullException(nameof(audioBackEnd)); - if (audioBackEnd.SupportsEndCallback) - audioBackEnd.OnSongEnd += (s, e) => OnSongEnd(); - else - waitEndTick = TickPool.RegisterTick(NotifyEnd, SongEndTimeoutInterval, false); + audioBackEnd.OnSongEnd += (s, e) => OnResourceEnd(true); audioFrameworkData = afd; playerConnection = audioBackEnd; playerConnection.Initialize(); - - PlaylistManager = playlistMgr; } - /// - /// Gets started at the beginning of a new resource. - /// It calls the stop event when a resource is finished. - /// Is used for player backends which are not supporting an end callback. - /// - private void NotifyEnd() - { - if (endTime < Util.GetNow()) - { - if (playerConnection.IsPlaying) - { - var playtime = playerConnection.Length; - var position = playerConnection.Position; - - var endspan = playtime - position; - endTime = Util.GetNow().Add(endspan); - } - else if (endTime + SongEndTimeout < Util.GetNow()) - { - Log.Write(Log.Level.Debug, "AF Song ended with default timeout"); - OnSongEnd(); - waitEndTick.Active = false; - } - } - } + private void OnResourceEnd(bool val) => OnResourceStopped?.Invoke(this, new SongEndEventArgs(val)); /// - /// Do NOT call this method directly! Use the FactoryManager instead. + /// Do NOT call this method directly! Use the instead. /// Stops the old resource and starts the new one. /// The volume gets resetted and the OnStartEvent gets triggered. /// - /// The info struct contaiting the AudioResource to start. - /// An infocode on what happened. - internal AudioResultCode StartResource(PlayData playData) + /// The info struct containing the PlayResource to start. + internal R StartResource(PlayResource playResource, MetaData config) { - if (playData == null || playData.Resource == null) + if (playResource == null) { Log.Write(Log.Level.Debug, "AF audioResource is null"); - return AudioResultCode.NoNewResource; + return "No new resource"; } Stop(true); - string resourceLink = playData.Resource.Play(); - if (string.IsNullOrWhiteSpace(resourceLink)) - return AudioResultCode.ResouceInternalError; + if (string.IsNullOrWhiteSpace(playResource.PlayUri)) + return "Internal resource error: link is empty"; - Log.Write(Log.Level.Debug, "AF ar start: {0}", playData.Resource); - playerConnection.AudioStart(resourceLink); + Log.Write(Log.Level.Debug, "AF ar start: {0}", playResource); + var result = playerConnection.AudioStart(playResource.PlayUri); + if (!result) + { + Log.Write(Log.Level.Error, "Error return from player: {0}", result.Message); + return $"Internal player error ({result.Message})"; + } - if (playData.Volume == -1) - Volume = audioFrameworkData.defaultVolume; - else - Volume = playData.Volume; + Volume = config.Volume ?? audioFrameworkData.defaultVolume; Log.Write(Log.Level.Debug, "AF set volume: {0}", Volume); - OnResourceStarted?.Invoke(this, playData); - CurrentPlayData = playData; - - if (!playerConnection.SupportsEndCallback) - { - endTime = Util.GetNow(); - waitEndTick.Active = true; - } - return AudioResultCode.Success; + return R.OkR; } - public void Stop() - { - Stop(false); - } + public void Stop() => Stop(false); /// Stops the currently played song. - /// When set to true, the AudioBob won't be notified aubout the stop. - /// Use this parameter to prevent fast off-on switching. private void Stop(bool restart) { - Log.Write(Log.Level.Debug, "AF stop old (restart:{0})", restart); - if (CurrentPlayData != null) - { - CurrentPlayData = null; - if (!restart) - playerConnection.AudioStop(); - OnResourceStopped?.Invoke(this, restart); - } + Log.Write(Log.Level.Debug, "AF stop old"); + + playerConnection.AudioStop(); + if (!restart) + OnResourceEnd(false); } public void Dispose() @@ -205,11 +177,16 @@ public void Dispose() { playerConnection.Dispose(); playerConnection = null; - Log.Write(Log.Level.Debug, "AF playerConnection disposed"); } } } + public class SongEndEventArgs : EventArgs + { + public bool SongEndedByCallback { get; } + public SongEndEventArgs(bool songEndedByCallback) { SongEndedByCallback = songEndedByCallback; } + } + public struct AudioFrameworkData { //[InfoAttribute("the absolute or relative path to the local music folder")] @@ -218,8 +195,6 @@ public struct AudioFrameworkData public int defaultVolume; [Info("the maximum volume a normal user can request")] public int maxUserVolume; - [Info("the location of the vlc player (if the vlc backend is used)", "vlc")] - public string vlcLocation; } public enum AudioType @@ -229,11 +204,4 @@ public enum AudioType Soundcloud, Twitch, } - - enum AudioResultCode - { - Success, - NoNewResource, - ResouceInternalError, - } } diff --git a/TS3AudioBot/BobController.cs b/TS3AudioBot/BobController.cs index 203128f5..5a1209ad 100644 --- a/TS3AudioBot/BobController.cs +++ b/TS3AudioBot/BobController.cs @@ -21,14 +21,15 @@ namespace TS3AudioBot using System.Globalization; using System.Linq; using Helper; - using TS3Query.Messages; + using TS3Client; + using TS3Client.Messages; public sealed class BobController : IPlayerConnection { private static readonly TimeSpan RequestTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan BobTimeout = TimeSpan.FromSeconds(60); - private QueryConnection queryConnection; + private ITeamspeakControl queryConnection; private BobControllerData bobControllerData; private TickWorker timeout; private DateTime lastUpdate = Util.GetNow(); @@ -41,7 +42,7 @@ public sealed class BobController : IPlayerConnection private readonly object lockObject = new object(); private ClientData bobClient; - private Dictionary channelSubscriptions; + private Dictionary channelSubscriptions; private bool sending = false; public bool Sending @@ -60,87 +61,63 @@ public bool Sending private bool repeated = false; private bool pause = false; - public bool SupportsEndCallback => true; public event EventHandler OnSongEnd; - public int Volume + public R GetVolume() => volume; + public void SetVolume(int value) { - get { return volume; } - set - { - volume = value; - SendMessage("music volume " + (value / 100d)); - } + volume = value; + SendMessage("music volume " + (value / 100d).ToString(CultureInfo.InvariantCulture)); } - public TimeSpan Position + public R GetPosition() { - get - { - SendMessage("status music"); - musicInfoWaiter.Wait(RequestTimeout); - return CurrentMusicInfo.Position; - } - set - { - SendMessage("music seek " + value.TotalSeconds.ToString(CultureInfo.InvariantCulture)); - } + SendMessage("status music"); + try { musicInfoWaiter.Wait(RequestTimeout); } catch (TimeoutException) { return "Request timed out"; } + return CurrentMusicInfo.Position; } - - public bool Repeated + public void SetPosition(TimeSpan value) { - get { return repeated; } - set - { - repeated = value; - SendMessage("music loop " + (value ? "on" : "off")); - } + SendMessage("music seek " + value.TotalSeconds.ToString(CultureInfo.InvariantCulture)); } - public bool Pause + public R IsRepeated() => repeated; + public void SetRepeated(bool value) { - get { return pause; } - set - { - pause = value; - SendMessage("music " + (value ? "pause" : "unpause")); - } + repeated = value; + SendMessage("music loop " + (value ? "on" : "off")); } - public TimeSpan Length + public R IsPaused() => pause; + public void SetPaused(bool value) { - get - { - SendMessage("status music"); - musicInfoWaiter.Wait(RequestTimeout); - return CurrentMusicInfo.Length; - } + pause = value; + SendMessage("music " + (value ? "pause" : "unpause")); } - public bool IsPlaying + public R GetLength() { - get - { - SendMessage("status music"); - musicInfoWaiter.Wait(RequestTimeout); - return CurrentMusicInfo.Status == MusicStatus.Playing; - } + SendMessage("status music"); + try { musicInfoWaiter.Wait(RequestTimeout); } catch (TimeoutException) { return "Request timed out"; } + return CurrentMusicInfo.Length; } - - public void AudioStart(string url) + public R IsPlaying() { - SendMessage("music start " + url); + SendMessage("status music"); + try { musicInfoWaiter.Wait(RequestTimeout); } catch (TimeoutException) { return "Request timed out"; } + return CurrentMusicInfo.Status == MusicStatus.Playing; } - public void AudioStop() - { - SendMessage("music stop"); - } + + public R AudioStart(string url) => SendMessage("music start " + url); + public R AudioStop() => SendMessage("music stop"); + + public void Initialize() { } #endregion - public BobController(BobControllerData data, QueryConnection queryConnection) + public BobController(BobControllerData data, ITeamspeakControl queryConnection) { if (queryConnection == null) throw new ArgumentNullException(nameof(queryConnection)); @@ -149,53 +126,65 @@ public BobController(BobControllerData data, QueryConnection queryConnection) musicInfoWaiter = new WaitEventBlock(); isRunning = false; awaitingConnect = false; - this.bobControllerData = data; + bobControllerData = data; this.queryConnection = queryConnection; queryConnection.OnMessageReceived += GetResponse; queryConnection.OnClientConnect += OnBobConnect; queryConnection.OnClientDisconnect += OnBobDisconnnect; - commandQueue = new Queue(); - channelSubscriptions = new Dictionary(); + Util.Init(ref commandQueue); + Util.Init(ref channelSubscriptions); } - public void Initialize() { } - #region SendMethods - public void SendMessage(string message) + public R SendMessage(string message) { if (isRunning) { lock (lockObject) - SendMessageRaw(message); + return SendMessageRaw(message); } else { Log.Write(Log.Level.Debug, "BC Enqueing: {0}", message); commandQueue.Enqueue(message); + return R.OkR; } } - private void SendMessageRaw(string message) + private R SendMessageRaw(string message) { if (bobClient == null) { - Log.Write(Log.Level.Debug, "BC bobClient is null! Message is lost: {0}", message); - return; + Log.Write(Log.Level.Error, "BC bobClient is null! Message is lost: {0}", message); + return "Internal BobController Error: bobClient == null"; } Log.Write(Log.Level.Debug, "BC sending to bobC: {0}", message); - queryConnection.SendMessage(message, bobClient); + try + { + queryConnection.SendMessage(message, bobClient.ClientId); + return R.OkR; + } + catch (TS3CommandException qcex) + { + Log.Write(Log.Level.Error, "BC failed to send to bobC ({0})", qcex.Message); + return R.Err(qcex.Message); + } } - private void SendQueue() + private R SendQueue() { if (!isRunning) throw new InvalidOperationException("The bob must run to send the commandQueue"); lock (lockObject) while (commandQueue.Count > 0) - SendMessageRaw(commandQueue.Dequeue()); + { + var result = SendMessageRaw(commandQueue.Dequeue()); + if (!result) return result; + } + return R.OkR; } #endregion @@ -260,7 +249,11 @@ private static MusicData ParseMusicData(IEnumerable input) switch (result[0]) { case "address": musicData.Address = result[1]; break; - case "length": musicData.Length = TimeSpan.FromSeconds(double.Parse(result[1], CultureInfo.InvariantCulture)); break; + case "length": + double length = double.Parse(result[1], CultureInfo.InvariantCulture); + // prevent exceptions when dealing with live streams + musicData.Length = TimeSpan.FromSeconds(Math.Max(0, length)); + break; case "loop": musicData.Loop = result[1] != "off"; break; case "position": musicData.Position = TimeSpan.FromSeconds(double.Parse(result[1], CultureInfo.InvariantCulture)); break; case "status": musicData.Status = (MusicStatus)Enum.Parse(typeof(MusicStatus), result[1], true); break; @@ -276,23 +269,19 @@ private static MusicData ParseMusicData(IEnumerable input) #region Bob & Events - internal void OnResourceStarted(object sender, PlayData playData) + internal void OnResourceStarted(object sender, PlayInfoEventArgs playData) { Log.Write(Log.Level.Debug, "BC Ressource started"); BobStart(); - Pause = false; + SetPaused(false); Sending = true; RestoreSubscriptions(playData.Invoker); } - internal void OnResourceStopped(object sender, bool restart) + internal void OnPlayStopped(object sender, EventArgs e) { - Log.Write(Log.Level.Debug, "BC Ressource ended ({0})", restart); - if (!restart) - { - Sending = false; - BobStop(); - } + Sending = false; + BobStop(); } private void BobStart() @@ -382,6 +371,7 @@ private void TimeoutCheck() { Log.Write(Log.Level.Debug, "BC Timeout ran out..."); BobExit(); + // TODO: add safety check after n times if bob actually exists... } } @@ -393,7 +383,7 @@ private void TimeoutCheck() /// The id of the channel. /// Should be true if the command was invoked by a user, /// or false if the channel is added automatically by a play command. - public void WhisperChannelSubscribe(int channel, bool manual) + public void WhisperChannelSubscribe(ulong channel, bool manual) { SendMessage("whisper channel add " + channel); SubscriptionData subscriptionData; @@ -410,7 +400,7 @@ public void WhisperChannelSubscribe(int channel, bool manual) /// The id of the channel. /// Should be true if the command was invoked by a user, /// or false if the channel was removed automatically by an internal stop. - public void WhisperChannelUnsubscribe(int channel, bool manual) + public void WhisperChannelUnsubscribe(ulong channel, bool manual) { SendMessage("whisper channel remove " + channel); SubscriptionData subscriptionData; @@ -457,7 +447,7 @@ private void RestoreSubscriptions(ClientData invokingUser) private class SubscriptionData { - public int Id { get; set; } + public ulong Id { get; set; } public bool Enabled { get; set; } public bool Manual { get; set; } } @@ -472,10 +462,11 @@ public void Dispose() musicInfoWaiter = null; } BobExit(); + isRunning = false; } } - public class MusicData : MarshalByRefObject + public class MusicData { public MusicStatus Status { get; set; } public TimeSpan Length { get; set; } diff --git a/TS3AudioBot/BotSession.cs b/TS3AudioBot/BotSession.cs index 3a649814..b1bcb603 100644 --- a/TS3AudioBot/BotSession.cs +++ b/TS3AudioBot/BotSession.cs @@ -17,79 +17,118 @@ namespace TS3AudioBot { using System; - using TS3Query; - using TS3Query.Messages; + using System.Collections.Generic; + using System.Threading; + using Helper; + using TS3Client; + using TS3Client.Messages; using Response = System.Func; - public abstract class BotSession : MarshalByRefObject + public sealed class UserSession { - public MainBot Bot { get; private set; } + private Dictionary assocMap = null; + private bool tokenToken = false; - public PlayData UserResource { get; set; } - public Response ResponseProcessor { get; protected set; } - public object ResponseData { get; protected set; } + public Response ResponseProcessor { get; private set; } + public object ResponseData { get; private set; } - public abstract bool IsPrivate { get; } + public MainBot Bot { get; } + public ClientData ClientCached { get; private set; } + public ClientData Client => ClientCached = Bot.QueryConnection.GetClientById(ClientCached.ClientId); + public bool IsPrivate { get; internal set; } - public abstract void Write(string message); - - protected BotSession(MainBot bot) + public UserSession(MainBot bot, ClientData client) { Bot = bot; - UserResource = null; + ClientCached = client; ResponseProcessor = null; ResponseData = null; } + public void Write(string message) + { + if (!tokenToken) + throw new InvalidOperationException("No token is currently active"); + + try + { + if (IsPrivate) + Bot.QueryConnection.SendMessage(message, ClientCached.ClientId); + else + Bot.QueryConnection.SendGlobalMessage(message); + } + catch (TS3CommandException ex) + { + Log.Write(Log.Level.Error, "Could not write public message ({0})", ex); + } + } + public void SetResponse(Response responseProcessor, object responseData) { + if (!tokenToken) + throw new InvalidOperationException("No token is currently active"); + ResponseProcessor = responseProcessor; ResponseData = responseData; } public void ClearResponse() { + if (!tokenToken) + throw new InvalidOperationException("No token is currently active"); + ResponseProcessor = null; ResponseData = null; } - } - - internal sealed class PublicSession : BotSession - { - public override bool IsPrivate { get { return false; } } - public override void Write(string message) + public R Get() { - try - { - Bot.QueryConnection.SendGlobalMessage(message); - } - catch (QueryCommandException ex) - { - Log.Write(Log.Level.Error, "Could not write public message ({0})", ex); - } + if (!tokenToken) + throw new InvalidOperationException("No token is currently active"); + + if (assocMap == null) + return "Value not set"; + + object value; + if (!assocMap.TryGetValue(typeof(TAssoc), out value)) + return "Value not set"; + + if (value?.GetType() != typeof(TData)) + return "Invalid request type"; + + return (TData)value; } - public PublicSession(MainBot bot) - : base(bot) - { } - } + public void Set(TData data) + { + if (!tokenToken) + throw new InvalidOperationException("No token is currently active"); - internal sealed class PrivateSession : BotSession - { - public ClientData Client { get; private set; } + if (assocMap == null) + Util.Init(ref assocMap); - public override bool IsPrivate { get { return true; } } + if (assocMap.ContainsKey(typeof(TAssoc))) + assocMap[typeof(TAssoc)] = data; + else + assocMap.Add(typeof(TAssoc), data); + } - public override void Write(string message) + public SessionToken GetToken(bool isPrivate) { - Bot.QueryConnection.SendMessage(message, Client); + var sessionToken = new SessionToken(this); + sessionToken.Take(); + IsPrivate = isPrivate; + return sessionToken; } - public PrivateSession(MainBot bot, ClientData client) - : base(bot) + public sealed class SessionToken : IDisposable { - Client = client; + private UserSession session; + public SessionToken(UserSession session) { this.session = session; } + + public void Take() { Monitor.Enter(session); session.tokenToken = true; } + public void Free() { Monitor.Exit(session); session.tokenToken = false; } + public void Dispose() => Free(); } } } diff --git a/TS3AudioBot/CommandSystem/BotCommand.cs b/TS3AudioBot/CommandSystem/BotCommand.cs index 08d9db48..6a65eda7 100644 --- a/TS3AudioBot/CommandSystem/BotCommand.cs +++ b/TS3AudioBot/CommandSystem/BotCommand.cs @@ -20,7 +20,7 @@ namespace TS3AudioBot.CommandSystem using System.Linq; using System.Reflection; using System.Text; - using TS3Query; + using TS3Client; using static CommandRights; public class BotCommand : FunctionCommand @@ -93,15 +93,15 @@ public override ICommandResult Execute(ExecutionInformation info, IEnumerable CommandPaths; public XCommandSystem CommandSystem { get; } public IList BaseCommands { get; private set; } + private List dynamicCommands; public IDictionary> PluginCommands { get; } public IEnumerable AllCommands @@ -37,6 +38,8 @@ public IEnumerable AllCommands { foreach (var com in BaseCommands) yield return com; + foreach (var com in dynamicCommands) + yield return com; foreach (var comArr in PluginCommands.Values) foreach (var com in comArr) yield return com; @@ -44,12 +47,13 @@ public IEnumerable AllCommands } } - private static readonly Regex CommandNamespaceValidator = new Regex(@"^[a-z]+( [a-z]+)*$", RegexOptions.Compiled); + private static readonly Regex CommandNamespaceValidator = new Regex(@"^[a-z]+( [a-z]+)*$", Util.DefaultRegexConfig & ~RegexOptions.IgnoreCase); public CommandManager() { CommandSystem = new XCommandSystem(); PluginCommands = new Dictionary>(); + Util.Init(ref dynamicCommands); Util.Init(ref CommandPaths); } @@ -68,6 +72,18 @@ public void RegisterMain(MainBot main) BaseCommands = comList.AsReadOnly(); } + public void RegisterCommand(BotCommand command) + { + LoadCommand(command); + dynamicCommands.Add(command); + } + + internal void RegisterCommand(ICommand command, string path) + { + LoadICommand(command, path); + // TODO: add BotCommand (tree-)scan + } + public void RegisterPlugin(Plugin plugin) { if (PluginCommands.ContainsKey(plugin)) @@ -147,21 +163,37 @@ private void LoadCommand(BotCommand com) // TODO test throw new InvalidOperationException("Command already exists: " + com.InvokeName); CommandPaths.Add(com.FullQualifiedName); - var comPath = com.InvokeName.Split(' '); + LoadICommand(com, com.InvokeName); + } + + private void LoadICommand(ICommand com, string path) + { + string[] comPath = path.Split(' '); + + var buildResult = BuildAndGet(comPath.Take(comPath.Length - 1)); + if (!buildResult) + GenerateError(buildResult.Message, com as BotCommand); + var result = InsertInto(buildResult.Value, com, comPath.Last()); + if (!result) + GenerateError(result.Message, com as BotCommand); + } + + private R BuildAndGet(IEnumerable comPath) + { CommandGroup group = CommandSystem.RootCommand; // this for loop iterates through the seperate names of // the command to be added. - for (int i = 0; i < comPath.Length - 1; i++) + foreach (var comPathPart in comPath) { - ICommand currentCommand = group.GetCommand(comPath[i]); + ICommand currentCommand = group.GetCommand(comPathPart); // if a group to hold the next level command doesn't exist // it will be created here if (currentCommand == null) { var nextGroup = new CommandGroup(); - group.AddCommand(comPath[i], nextGroup); + group.AddCommand(comPathPart, nextGroup); group = nextGroup; } // if the group already exists we can take it. @@ -174,42 +206,28 @@ private void LoadCommand(BotCommand com) // TODO test else if (currentCommand is FunctionCommand) { var subGroup = new CommandGroup(); - group.RemoveCommand(comPath[i]); - group.AddCommand(comPath[i], subGroup); - - var botCom = currentCommand as BotCommand; - if (botCom != null && botCom.NormalParameters > 0) - Log.Write(Log.Level.Warning, "\"{0}\" has at least one parameter and won't be reachable due to an overloading function.", botCom.InvokeName); - subGroup.AddCommand(string.Empty, currentCommand); + group.RemoveCommand(comPathPart); + group.AddCommand(comPathPart, subGroup); + if (!InsertInto(group, currentCommand, comPathPart)) + throw new InvalidOperationException("Unexpected group error"); group = subGroup; } else - throw new InvalidOperationException("An overloaded command cannot be replaced by a CommandGroup: " + com.InvokeName); + return "An overloaded command cannot be replaced by a CommandGroup"; } - ICommand subCommand = group.GetCommand(comPath.Last()); + return group; + } + + private R InsertInto(CommandGroup group, ICommand com, string name) + { + ICommand subCommand = group.GetCommand(name); // the group we are trying to insert has no element with the current // name, so just insert it if (subCommand == null) { - group.AddCommand(comPath.Last(), com); - return; - } - // if we have a simple function, we need to create a overlaoder - // and then add both functions to it - else if (subCommand is FunctionCommand) - { - group.RemoveCommand(comPath.Last()); - var overloader = new OverloadedFunctionCommand(); - overloader.AddCommand((FunctionCommand)subCommand); - overloader.AddCommand(com); - group.AddCommand(comPath.Last(), overloader); - } - // if we have a overloaded function, we can simply add it - else if (subCommand is OverloadedFunctionCommand) - { - var insertCommand = (OverloadedFunctionCommand)subCommand; - insertCommand.AddCommand(com); + group.AddCommand(name, com); + return R.OkR; } // to add a command to CommandGroup will have to treat it as a subcommand // with an empty string as a name @@ -221,14 +239,47 @@ private void LoadCommand(BotCommand com) // TODO test if (noparamCommand == null) { insertCommand.AddCommand(string.Empty, com); - if (com.NormalParameters > 0) - Log.Write(Log.Level.Warning, "parameter of an empty named function under a group will be ignored!!"); + var botCom = com as BotCommand; + if (botCom != null && botCom.NormalParameters > 0) + Log.Write(Log.Level.Warning, $"\"{botCom.FullQualifiedName}\" has at least one parameter and won't be reachable due to an overloading function."); + return R.OkR; } else - throw new InvalidOperationException("An empty named function under a group cannot be overloaded (" + com.InvokeName + ")"); + return "An empty named function under a group cannot be overloaded."; + } + + FunctionCommand funcCom = com as FunctionCommand; + if (funcCom == null) + return $"The command cannot be inserted into a complex node ({name})."; + + // if we have is a simple function, we need to create a overlaoder + // and then add both functions to it + if (subCommand is FunctionCommand) + { + group.RemoveCommand(name); + var overloader = new OverloadedFunctionCommand(); + overloader.AddCommand((FunctionCommand)subCommand); + overloader.AddCommand(funcCom); + group.AddCommand(name, overloader); + } + // if we have a overloaded function, we can simply add it + else if (subCommand is OverloadedFunctionCommand) + { + var insertCommand = (OverloadedFunctionCommand)subCommand; + insertCommand.AddCommand(funcCom); } else - throw new InvalidOperationException("Unknown insertion error with " + com.FullQualifiedName); + return "Unknown node to insert to."; + + return R.OkR; + } + + private void GenerateError(string msg, BotCommand involvedCom) + { + throw new InvalidOperationException( + $@"Command error path: {involvedCom?.InvokeName} + Command: {involvedCom?.FullQualifiedName} + Error: {msg}"); } private void UnloadCommand(BotCommand com) diff --git a/TS3AudioBot/CommandSystem/CommandResults/ICommandResult.cs b/TS3AudioBot/CommandSystem/CommandResults/ICommandResult.cs index 4ac6b548..8939168b 100644 --- a/TS3AudioBot/CommandSystem/CommandResults/ICommandResult.cs +++ b/TS3AudioBot/CommandSystem/CommandResults/ICommandResult.cs @@ -18,7 +18,7 @@ namespace TS3AudioBot.CommandSystem { using System; - public abstract class ICommandResult : MarshalByRefObject + public abstract class ICommandResult { public abstract CommandResultType ResultType { get; } diff --git a/TS3AudioBot/CommandSystem/Commands/AppliedCommand.cs b/TS3AudioBot/CommandSystem/Commands/AppliedCommand.cs index 446d524f..333bab6a 100644 --- a/TS3AudioBot/CommandSystem/Commands/AppliedCommand.cs +++ b/TS3AudioBot/CommandSystem/Commands/AppliedCommand.cs @@ -16,7 +16,6 @@ namespace TS3AudioBot.CommandSystem { - using System; using System.Linq; using System.Collections.Generic; diff --git a/TS3AudioBot/CommandSystem/Commands/FunctionCommand.cs b/TS3AudioBot/CommandSystem/Commands/FunctionCommand.cs index e5549fd4..fe15537b 100644 --- a/TS3AudioBot/CommandSystem/Commands/FunctionCommand.cs +++ b/TS3AudioBot/CommandSystem/Commands/FunctionCommand.cs @@ -18,6 +18,7 @@ namespace TS3AudioBot.CommandSystem { using System; using System.Collections.Generic; + using System.Globalization; using System.Linq; using System.Reflection; @@ -76,6 +77,7 @@ protected virtual object ExecuteFunction(object[] parameters) } catch (TargetInvocationException ex) { + System.Runtime.ExceptionServices.ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); throw ex.InnerException; } } @@ -217,7 +219,7 @@ static object ConvertParam(string value, Type targetType) if (targetType.IsConstructedGenericType && targetType.GetGenericTypeDefinition() == typeof(Nullable<>)) targetType = targetType.GenericTypeArguments[0]; - return Convert.ChangeType(value, targetType); + return Convert.ChangeType(value, targetType, CultureInfo.InvariantCulture); } public static object GetDefault(Type type) diff --git a/TS3AudioBot/CommandSystem/Commands/ICommand.cs b/TS3AudioBot/CommandSystem/Commands/ICommand.cs index 31ab991f..82ac504f 100644 --- a/TS3AudioBot/CommandSystem/Commands/ICommand.cs +++ b/TS3AudioBot/CommandSystem/Commands/ICommand.cs @@ -19,7 +19,7 @@ namespace TS3AudioBot.CommandSystem using System; using System.Collections.Generic; - public abstract class ICommand : MarshalByRefObject + public abstract class ICommand { /// Execute this command. /// All global informations for this execution. diff --git a/TS3AudioBot/CommandSystem/ExecutionInformation.cs b/TS3AudioBot/CommandSystem/ExecutionInformation.cs index 6bd4b82d..318e459e 100644 --- a/TS3AudioBot/CommandSystem/ExecutionInformation.cs +++ b/TS3AudioBot/CommandSystem/ExecutionInformation.cs @@ -17,20 +17,21 @@ namespace TS3AudioBot.CommandSystem { using System; - using TS3Query.Messages; + using TS3Client.Messages; - public class ExecutionInformation : MarshalByRefObject + public class ExecutionInformation { - public BotSession Session { get; } + public UserSession Session { get; } public TextMessage TextMessage { get; } - public Lazy IsAdmin { get; } + private Lazy lazyIsAdmin; + public bool IsAdmin => lazyIsAdmin.Value; - private ExecutionInformation() { Session = null; TextMessage = null; IsAdmin = new Lazy(() => true); } - public ExecutionInformation(BotSession session, TextMessage textMessage, Lazy isAdmin) + private ExecutionInformation() { Session = null; TextMessage = null; lazyIsAdmin = new Lazy(() => true); } + public ExecutionInformation(UserSession session, TextMessage textMessage, Lazy isAdmin) { Session = session; TextMessage = textMessage; - IsAdmin = isAdmin; + lazyIsAdmin = isAdmin; } public static readonly ExecutionInformation Debug = new ExecutionInformation(); diff --git a/TS3AudioBot/CommandSystem/XCommandSystem.cs b/TS3AudioBot/CommandSystem/XCommandSystem.cs index 746d67bd..41c03584 100644 --- a/TS3AudioBot/CommandSystem/XCommandSystem.cs +++ b/TS3AudioBot/CommandSystem/XCommandSystem.cs @@ -36,7 +36,7 @@ public static IEnumerable> FilterList(IEnumerable new FilterItem(t.Key, t.Value, 0)).ToList(); // Filter matching commands - foreach (var c in filter) + foreach (var c in filter.ToLowerInvariant()) { var newPossibilities = (from p in possibilities let pos = p.name.IndexOf(c, p.index) diff --git a/TS3AudioBot/Helper/AudioTags/AudioTagReader.cs b/TS3AudioBot/Helper/AudioTags/AudioTagReader.cs index 9e774b36..736c0aa8 100644 --- a/TS3AudioBot/Helper/AudioTags/AudioTagReader.cs +++ b/TS3AudioBot/Helper/AudioTags/AudioTagReader.cs @@ -82,7 +82,6 @@ class Id3_2 : Tag public override string TagID { get { return "ID3"; } } -#pragma warning disable CS0219 public override string GetTitle(BinaryReader fileStream) { // using the official id3 tag documentation @@ -93,8 +92,8 @@ public override string GetTitle(BinaryReader fileStream) // read + validate header [10 bytes] // skipped for TagID >03 bytes byte version_major = fileStream.ReadByte(); // >01 bytes - byte version_minor = fileStream.ReadByte(); // >01 bytes - byte data_flags = fileStream.ReadByte(); // >01 bytes + /*byte version_minor =*/ fileStream.ReadByte(); // >01 bytes + /*byte data_flags =*/ fileStream.ReadByte(); // >01 bytes byte[] tag_size = fileStream.ReadBytes(4); // >04 bytes int tag_size_int = 0; for (int i = 0; i < 4; i++) @@ -135,10 +134,10 @@ public override string GetTitle(BinaryReader fileStream) { while (read_count < tag_size_int + 10) { - // frame header [10 bytes] - uint frame_id = fileStream.ReadUInt32BE(); // >04 bytes - int frame_size = fileStream.ReadInt32BE(); // >04 bytes - ushort frame_flags = fileStream.ReadUInt16BE(); // >02 bytes + // frame header [10 bytes] + uint frame_id = fileStream.ReadUInt32BE(); // >04 bytes + int frame_size = fileStream.ReadInt32BE(); // >04 bytes + /*ushort frame_flags =*/ fileStream.ReadUInt16BE(); // >02 bytes read_count += 10; // content @@ -175,7 +174,6 @@ public override string GetTitle(BinaryReader fileStream) #endregion return null; } -#pragma warning restore private static int FrameIdV2(string id) { diff --git a/TS3AudioBot/Helper/ConfigFile.cs b/TS3AudioBot/Helper/ConfigFile.cs index 06e0af4f..c58e5689 100644 --- a/TS3AudioBot/Helper/ConfigFile.cs +++ b/TS3AudioBot/Helper/ConfigFile.cs @@ -21,11 +21,12 @@ namespace TS3AudioBot.Helper using System.IO; using System.Reflection; - public class ConfigFile : MarshalByRefObject + public class ConfigFile { private string path; private readonly Dictionary data; private bool changed; + private static readonly char[] splitChar = new[] { '=' }; private ConfigFile() { @@ -46,18 +47,18 @@ public static ConfigFile Open(string pPath) return null; } - using (FileStream fs = File.Open(pPath, FileMode.Open, FileAccess.Read, FileShare.Read)) + using (StreamReader input = new StreamReader(File.Open(pPath, FileMode.Open, FileAccess.Read, FileShare.Read))) { - using (StreamReader input = new StreamReader(fs)) + while (!input.EndOfStream) { - while (!input.EndOfStream) + string s = input.ReadLine(); + if (!s.StartsWith(";", StringComparison.Ordinal) + && !s.StartsWith("//", StringComparison.Ordinal) + && !s.StartsWith("#", StringComparison.Ordinal)) { - string s = input.ReadLine(); - if (!s.StartsWith(";") && !s.StartsWith("//") && !s.StartsWith("#")) - { - int index = s.IndexOf('='); - cfgFile.data.Add(s.Substring(0, index).Trim(), s.Substring(index + 1).Trim()); - } + string[] kvp = s.Split(splitChar, 2); + if (kvp.Length < 2) { Console.WriteLine("Invalid log entry: \"{0}\"", s); continue; } + cfgFile.data.Add(kvp[0], kvp[1]); } } } @@ -68,8 +69,7 @@ public static ConfigFile Create(string pPath) { try { - FileStream fs = File.Create(pPath); - fs.Close(); + using (FileStream fs = File.Create(pPath)) { } return new ConfigFile { path = pPath, @@ -85,7 +85,7 @@ public static ConfigFile Create(string pPath) /// Creates a dummy object which cannot save or read values. /// Its only purpose is to show the console dialog and create a DataStruct /// Returns a dummy-ConfigFile - public static ConfigFile GetDummy() + public static ConfigFile CreateDummy() { return new DummyConfigFile(); } @@ -231,18 +231,15 @@ public virtual void Close() return; } - using (FileStream fs = File.Open(path, FileMode.Create, FileAccess.Write)) + using (StreamWriter output = new StreamWriter(File.Open(path, FileMode.Create, FileAccess.Write))) { - using (StreamWriter output = new StreamWriter(fs)) + foreach (string key in data.Keys) { - foreach (string key in data.Keys) - { - output.Write(key); - output.Write('='); - output.WriteLine(data[key]); - } - output.Flush(); + output.Write(key); + output.Write('='); + output.WriteLine(data[key]); } + output.Flush(); } } diff --git a/TS3AudioBot/Helper/R.cs b/TS3AudioBot/Helper/R.cs new file mode 100644 index 00000000..fcb6b1d4 --- /dev/null +++ b/TS3AudioBot/Helper/R.cs @@ -0,0 +1,60 @@ +namespace TS3AudioBot.Helper +{ + /// + /// The R result wrapper. + /// The functionality is quite similar to the optional-patter. + /// It either represents success or an error + message + /// + public struct R + { + public static readonly R OkR = new R(); + + // using default false bool so Ok is true on default + private bool isError; + public bool Ok => !isError; + public string Message { get; } + + private R(string message) { isError = true; Message = message; } + /// Creates a new failed result with a message + /// The message + public static R Err(string message) => new R(message); + + public static implicit operator bool(R result) => result.Ok; + public static implicit operator string(R result) => result.Message; + + public static implicit operator R(string message) => new R(message); + + public override string ToString() => Message; + } + + /// + /// The R<T> result wrapper. + /// The functionality is quite similar to the optional-patter. + /// It either represents success + value or an error + message + /// + public struct R + { + private bool isError; + public bool Ok => !isError; + public string Message { get; } + public T Value { get; } + + private R(T value) { isError = false; Message = null; if (value == null) throw new System.Exception("Return of ok must not be null."); Value = value; } + private R(string message) { isError = true; Message = message; Value = default(T); } + + /// Creates a new failed result with a message + /// The message + public static R Err(string message) => new R(message); + /// Creates a new successful result with a value + /// The value + public static R OkR(T value) => new R(value); + + public static implicit operator bool(R result) => result.Ok; + public static implicit operator string(R result) => result.Message; + + public static implicit operator R(T result) => new R(result); + public static implicit operator R(string message) => new R(message); + + public override string ToString() => Message; + } +} diff --git a/TS3AudioBot/Helper/TextUtil.cs b/TS3AudioBot/Helper/TextUtil.cs index c086ab91..7a045d6d 100644 --- a/TS3AudioBot/Helper/TextUtil.cs +++ b/TS3AudioBot/Helper/TextUtil.cs @@ -17,6 +17,7 @@ namespace TS3AudioBot.Helper { using System; + using System.Globalization; using System.Text.RegularExpressions; [Serializable] @@ -34,22 +35,26 @@ public static string[] SplitNoEmpty(this string value, char splitChar) public static Answer GetAnswer(string answer) { - string lowAnswer = answer.ToLower(); - if (lowAnswer.StartsWith("!y")) + string lowAnswer = answer.ToLower(CultureInfo.InvariantCulture); + if (lowAnswer.StartsWith("!y", StringComparison.Ordinal)) return Answer.Yes; - else if (lowAnswer.StartsWith("!n")) + else if (lowAnswer.StartsWith("!n", StringComparison.Ordinal)) return Answer.No; else return Answer.Unknown; } - private static readonly Regex bbMatch = new Regex(@"\[URL\](.+?)\[\/URL\]", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex bbMatch = new Regex(@"\[URL\](.+?)\[\/URL\]", Util.DefaultRegexConfig); public static string ExtractUrlFromBB(string ts3link) { if (ts3link.Contains("[URL]")) - return Regex.Match(ts3link, @"\[URL\](.+?)\[\/URL\]").Groups[1].Value; - else - return ts3link; + { + var match = bbMatch.Match(ts3link); + if (match.Success) + return match.Groups[1].Value; + } + + return ts3link; } public static string RemoveUrlBB(string ts3link) @@ -60,8 +65,8 @@ public static string RemoveUrlBB(string ts3link) public static string StripQuotes(string quotedString) { if (quotedString.Length <= 1 || - !quotedString.StartsWith("\"") || - !quotedString.EndsWith("\"")) + !quotedString.StartsWith("\"", StringComparison.Ordinal) || + !quotedString.EndsWith("\"", StringComparison.Ordinal)) throw new ArgumentException("The string is not properly quoted"); return quotedString.Substring(1, quotedString.Length - 2); diff --git a/TS3AudioBot/Helper/TickPool.cs b/TS3AudioBot/Helper/TickPool.cs index 8fa95701..225f7d8f 100644 --- a/TS3AudioBot/Helper/TickPool.cs +++ b/TS3AudioBot/Helper/TickPool.cs @@ -40,12 +40,22 @@ static TickPool() tickThread.Name = "TickPool"; } + public static void RegisterTickOnce(Action method) + { + AddWorker(new TickWorker(method, TimeSpan.Zero) { Active = true, TickOnce = true }); + } + public static TickWorker RegisterTick(Action method, TimeSpan interval, bool active) { if (method == null) throw new ArgumentNullException(nameof(method)); if (interval <= TimeSpan.Zero) throw new ArgumentException("The parameter must be at least '1'", nameof(interval)); - var worker = new TickWorker(method, interval); - worker.Active = active; + var worker = new TickWorker(method, interval) { Active = active }; + AddWorker(worker); + return worker; + } + + private static void AddWorker(TickWorker worker) + { lock (workList) { workList.Add(worker); @@ -56,20 +66,21 @@ public static TickWorker RegisterTick(Action method, TimeSpan interval, bool act tickThread.Start(); } } - return worker; } public static void UnregisterTicker(TickWorker worker) { if (worker == null) throw new ArgumentNullException(nameof(worker)); - lock (workList) - { - workList.Remove(worker); - if (workList.Count > 0) - curTick = workList.Min(w => w.Interval); - else - curTick = minTick; - } + lock (workList) { RemoveUnlocked(worker); } + } + + private static void RemoveUnlocked(TickWorker worker) + { + workList.Remove(worker); + if (workList.Count > 0) + curTick = workList.Min(w => w.Interval); + else + curTick = minTick; } private static void Tick() @@ -78,8 +89,9 @@ private static void Tick() { lock (workList) { - foreach (var worker in workList) + for (int i = 0; i < workList.Count; i++) { + var worker = workList[i]; if (!worker.Active) continue; worker.IntervalRemain -= curTick; if (worker.IntervalRemain <= TimeSpan.Zero) @@ -87,6 +99,11 @@ private static void Tick() worker.IntervalRemain = worker.Interval; worker.Method.Invoke(); } + if (worker.TickOnce) + { + RemoveUnlocked(worker); + i--; + } } } @@ -97,15 +114,18 @@ private static void Tick() public static void Close() { run = false; + Util.WaitForThreadEnd(tickThread, TimeSpan.FromMilliseconds(100)); + tickThread = null; } } - public class TickWorker : MarshalByRefObject + public class TickWorker { public Action Method { get; } public TimeSpan Interval { get; } - public TimeSpan IntervalRemain { get; set; } - public bool Active { get; set; } + public TimeSpan IntervalRemain { get; set; } = TimeSpan.Zero; + public bool Active { get; set; } = false; + public bool TickOnce { get; set; } = false; public TickWorker(Action method, TimeSpan interval) { Method = method; Interval = interval; } } diff --git a/TS3AudioBot/Helper/Util.cs b/TS3AudioBot/Helper/Util.cs index 02c2e206..24b3cd27 100644 --- a/TS3AudioBot/Helper/Util.cs +++ b/TS3AudioBot/Helper/Util.cs @@ -22,6 +22,11 @@ namespace TS3AudioBot.Helper using System.Diagnostics; using System.Linq; using System.Threading; + using System.Reflection; + using System.IO; + using System.Security.Principal; + using System.Web.Script.Serialization; + using System.Text.RegularExpressions; [Serializable] public static class Util @@ -58,19 +63,156 @@ public static bool Execute(string path) public static IEnumerable TakeLast(this IEnumerable source, int amount) { - return source.Skip(Math.Max(0, source.Count() - amount)); + // if the array has fixed length like a list or array we can cast it to IList und use skip + count efficiently + var list = source as IList; + if (list != null) + return list.Skip(Math.Max(0, list.Count - amount)); + + // otherwise we need to evaluate the entire array + var temp = new LinkedList(); + foreach (var value in source) + { + temp.AddLast(value); + if (temp.Count > amount) + temp.RemoveFirst(); + } + return temp; } - public static void WaitOrTimeout(Func predicate, int msTimeout) + /// Blocks the thread while the predicate returns false or until the timeout runs out. + /// Check function that will be called every millisecond. + /// Timeout in millisenconds. + public static void WaitOrTimeout(Func predicate, TimeSpan timeout) { + int msTimeout = (int)timeout.TotalSeconds; while (!predicate() && msTimeout-- > 0) Thread.Sleep(1); } + public static void WaitForThreadEnd(Thread thread, TimeSpan timeout) + { + if (thread != null && thread.IsAlive) + { + WaitOrTimeout(() => thread.IsAlive, timeout); + if (thread.IsAlive) + { + thread.Abort(); + } + } + } + public static DateTime GetNow() => DateTime.Now; public static void Init(ref T obj) where T : new() => obj = new T(); public static Random RngInstance { get; } = new Random(); + + public static JavaScriptSerializer Serializer { get; } = new JavaScriptSerializer(); + + public static byte[] GetResource(string file) + { + using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(file)) + { + if (stream == null) + throw new InvalidOperationException("Resource not found"); + using (MemoryStream ms = new MemoryStream()) + { + stream.CopyTo(ms); + return ms.ToArray(); + } + } + } + + public static bool IsAdmin + { + get + { + try + { + using (WindowsIdentity user = WindowsIdentity.GetCurrent()) + { + WindowsPrincipal principal = new WindowsPrincipal(user); + return principal.IsInRole(WindowsBuiltInRole.Administrator); + } + } + catch (UnauthorizedAccessException) { return false; } + catch (Exception) + { + Log.Write(Log.Level.Warning, "Uncatched admin check."); + return false; + } + } + } + + public static bool RegisterFolderEvents(DirectoryInfo dir, FileSystemEventHandler callback) + { + if (!IsAdmin) + return false; + + if (!dir.Exists) + return false; + + var watcher = new FileSystemWatcher + { + Path = dir.FullName, + NotifyFilter = NotifyFilters.LastWrite, + }; + watcher.Changed += callback; + watcher.EnableRaisingEvents = true; + return true; + } + + public static int MathMod(int x, int mod) => ((x % mod) + mod) % mod; + + private static long Pow(long b, int pow) + { + long ret = 1; + while (pow != 0) + { + if ((pow & 1) == 1) + ret *= b; + b *= b; + pow >>= 1; + } + return ret; + } + + public static string FromSeed(int seed) + { + char[] seedstr = new char[7]; + uint plainseed = unchecked((uint)seed); + for (int i = 0; i < 7; i++) + { + if (plainseed > 26) + { + seedstr[i] = (char)((plainseed % 26) + 'a' - 1); + plainseed /= 26; + } + else if (plainseed > 0) + { + seedstr[i] = (char)(plainseed + 'a' - 1); + plainseed = 0; + } + else + seedstr[i] = '\0'; + } + return new string(seedstr).TrimEnd('\0'); + } + + public static int ToSeed(string seed) + { + long finalValue = 0; + + for (int i = 0; i < seed.Length; i++) + { + long powVal = (seed[i] - 'a' + 1) * Pow(26, i % 7); + finalValue += powVal; + finalValue %= ((long)uint.MaxValue + 1); + } + uint uval = (uint)finalValue; + return unchecked((int)uval); + } + + public const RegexOptions DefaultRegexConfig = RegexOptions.Compiled | RegexOptions.IgnoreCase | RegexOptions.ECMAScript; } } diff --git a/TS3AudioBot/Helper/WaitEventBlock.cs b/TS3AudioBot/Helper/WaitEventBlock.cs index b2f793f4..52f053b0 100644 --- a/TS3AudioBot/Helper/WaitEventBlock.cs +++ b/TS3AudioBot/Helper/WaitEventBlock.cs @@ -19,7 +19,7 @@ namespace TS3AudioBot.Helper using System; using System.Threading; - public sealed class WaitEventBlock : MarshalByRefObject, IDisposable + public sealed class WaitEventBlock : IDisposable { private T response; private AutoResetEvent blocker; diff --git a/TS3AudioBot/Helper/WebWrapper.cs b/TS3AudioBot/Helper/WebWrapper.cs index dd2c0231..6b255a89 100644 --- a/TS3AudioBot/Helper/WebWrapper.cs +++ b/TS3AudioBot/Helper/WebWrapper.cs @@ -24,9 +24,11 @@ internal static class WebWrapper { private static TimeSpan DefaultTimeout = TimeSpan.FromSeconds(1); - public static bool DownloadString(out string site, Uri link) + public static bool DownloadString(out string site, Uri link, params Tuple[] optionalHeaders) { var request = WebRequest.Create(link); + foreach (var header in optionalHeaders) + request.Headers.Add(header.Item1, header.Item2); try { using (var response = request.GetResponse()) diff --git a/TS3AudioBot/History/AudioLogEntry.cs b/TS3AudioBot/History/AudioLogEntry.cs index d168ed0f..edd4087e 100755 --- a/TS3AudioBot/History/AudioLogEntry.cs +++ b/TS3AudioBot/History/AudioLogEntry.cs @@ -21,7 +21,7 @@ namespace TS3AudioBot.History using System.Text; using ResourceFactories; - public class AudioLogEntry : AudioResource + public class AudioLogEntry { /// A unique id for each , given by the history system. public uint Id { get; } @@ -31,15 +31,24 @@ public class AudioLogEntry : AudioResource public uint PlayCount { get; set; } /// The last time this song has been played. public DateTime Timestamp { get; set; } - public override AudioType AudioType { get; } /// Zero based offset this entry is stored in the history file. public long FilePosIndex { get; set; } - public AudioLogEntry(uint id, AudioType audioType, string resId) : base(resId, null) + public AudioResource AudioResource { get; private set; } + + public AudioLogEntry(uint id, AudioResource resource) { Id = id; PlayCount = 0; - AudioType = audioType; + AudioResource = resource; + } + + public AudioLogEntry(uint id, string resourceId, string resourceTitle, AudioType type) + : this(id, new AudioResource(resourceId, resourceTitle, type)) { } + + public void SetName(string newName) + { + AudioResource = AudioResource.WithName(newName); } public string ToFileString() @@ -56,11 +65,11 @@ public string ToFileString() strb.Append(","); // OTHER STRINGS - strb.Append(AudioType.ToString()); + strb.Append(AudioResource.AudioType.ToString()); strb.Append(","); - strb.Append(Uri.EscapeDataString(ResourceId)); + strb.Append(Uri.EscapeDataString(AudioResource.ResourceId)); strb.Append(","); - strb.Append(Uri.EscapeDataString(ResourceTitle)); + strb.Append(Uri.EscapeDataString(AudioResource.ResourceTitle)); return strb.ToString(); } @@ -72,21 +81,20 @@ public static AudioLogEntry Parse(string line, long readIndex) return null; // Array.ForEach(strParts) // check if spacetrims are needed int index = 0; - uint id = uint.Parse(strParts[index++], NumberStyles.HexNumber); - uint userInvId = uint.Parse(strParts[index++], NumberStyles.HexNumber); - uint playCount = uint.Parse(strParts[index++], NumberStyles.HexNumber); - long dtStamp = long.Parse(strParts[index++], NumberStyles.HexNumber); + uint id = uint.Parse(strParts[index++], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + uint userInvId = uint.Parse(strParts[index++], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + uint playCount = uint.Parse(strParts[index++], NumberStyles.HexNumber, CultureInfo.InvariantCulture); + long dtStamp = long.Parse(strParts[index++], NumberStyles.HexNumber, CultureInfo.InvariantCulture); DateTime dateTime = DateTime.FromFileTime(dtStamp); AudioType audioType; if (!Enum.TryParse(strParts[index++], out audioType)) return null; string resId = Uri.UnescapeDataString(strParts[index++]); string title = Uri.UnescapeDataString(strParts[index++]); - return new AudioLogEntry(id, audioType, resId) + return new AudioLogEntry(id, resId, title, audioType) { PlayCount = playCount, Timestamp = dateTime, - ResourceTitle = title, UserInvokeId = userInvId, FilePosIndex = readIndex, }; @@ -97,12 +105,7 @@ public static AudioLogEntry Parse(string line, long readIndex) public override string ToString() { - return string.Format(CultureInfo.InvariantCulture, "[{0}] @ {1} by {2}: {3}, ({4})", Id, Timestamp, UserInvokeId, ResourceTitle, ResourceId); - } - - public override string Play() - { - throw new NotSupportedException(); + return string.Format(CultureInfo.InvariantCulture, "[{0}] @ {1} by {2}: {3}, ({4})", Id, Timestamp, UserInvokeId, AudioResource.ResourceTitle, AudioResource); } } diff --git a/TS3AudioBot/History/HistoryFile.cs b/TS3AudioBot/History/HistoryFile.cs index 2a1b790f..ca1f9b19 100644 --- a/TS3AudioBot/History/HistoryFile.cs +++ b/TS3AudioBot/History/HistoryFile.cs @@ -26,15 +26,17 @@ namespace TS3AudioBot.History using Helper; using ResourceFactories; - public class HistoryFile : IDisposable + public sealed class HistoryFile : IDisposable { private IDictionary resIdToId; private IDictionary idFilter; private ISubstringSearch titleFilter; private IDictionary> userIdFilter; private SortedList timeFilter; + private LinkedList unusedIds; public uint CurrentID { get; private set; } = 0; + public bool ReuseUnusedIds { get; set; } private readonly IList noResult = new List().AsReadOnly(); @@ -55,6 +57,7 @@ public HistoryFile() titleFilter = new SimpleSubstringFinder(); userIdFilter = new SortedList>(); timeFilter = new SortedList(); + unusedIds = new LinkedList(); } public void OpenFile(string path) @@ -97,7 +100,7 @@ private void VersionCheckAndUpgrade() } int fileVersion = -1; - if (!line.StartsWith(VersionHeader) + if (!line.StartsWith(VersionHeader, StringComparison.Ordinal) || !int.TryParse(line.Substring(VersionHeader.Length), out fileVersion)) throw new FormatException("The history file has an invalid header."); @@ -149,6 +152,13 @@ private void RestoreFromFile() } readIndex = fileReader.ReadPosition; } + + // fill up unused-id list + for(uint i = 0; i < CurrentID; i++) + { + if (GetEntryById(i) == null) + unusedIds.AddLast(i); + } } private void WriteHeader() @@ -158,7 +168,7 @@ private void WriteHeader() fileStream.Write(NewLineArray, 0, NewLineArray.Length); } - private void BackupFile() + public void BackupFile() { int backUpNum = 0; string fileName; @@ -173,15 +183,16 @@ private void BackupFile() } - public void Store(PlayData playData) + public AudioLogEntry Store(HistorySaveData saveData) { - if (playData == null) - throw new ArgumentNullException(nameof(playData)); + if (saveData == null) + throw new ArgumentNullException(nameof(saveData)); - uint? index = Contains(playData.Resource); + AudioLogEntry ale; + uint? index = Contains(saveData.Resource); if (!index.HasValue) { - var ale = CreateLogEntry(playData); + ale = CreateLogEntry(saveData); if (ale != null) { AddToMemoryIndex(ale); @@ -192,9 +203,10 @@ public void Store(PlayData playData) } else { - var ale = GetEntryById(index.Value); + ale = GetEntryById(index.Value); LogEntryPlay(ale); } + return ale; } public uint? Contains(AudioResource resource) @@ -279,6 +291,8 @@ public IList GetLastXEntrys(int idAmount) return result; } + public IList GetAll() => idFilter.Values.ToList(); + // User features /// Increases the playcount and updates the last playtime. @@ -313,7 +327,7 @@ public void LogEntryRename(AudioLogEntry ale, string newName, bool flush = true) throw new ArgumentNullException(nameof(newName)); // update the name - ale.ResourceTitle = newName; + ale.SetName(newName); if (flush) ReWriteToFile(ale); } @@ -324,32 +338,45 @@ public void LogEntryRemove(AudioLogEntry ale) { if (ale == null) throw new ArgumentNullException(nameof(ale)); + if (!Contains(ale.AudioResource).HasValue) + throw new ArgumentException("The requested entry was not found."); - RemoveFromFile(ale); RemoveFromMemoryIndex(ale); + RemoveFromFile(ale); } // Internal features - private AudioLogEntry CreateLogEntry(PlayData playData) + private AudioLogEntry CreateLogEntry(HistorySaveData saveData) { - var resource = playData.Resource; - if (string.IsNullOrWhiteSpace(resource.ResourceTitle)) + if (string.IsNullOrWhiteSpace(saveData.Resource.ResourceTitle)) return null; - var ale = new AudioLogEntry(CurrentID, resource.AudioType, resource.ResourceId) + + uint nextHid; + if (ReuseUnusedIds && unusedIds.Any()) + { + nextHid = unusedIds.First.Value; + unusedIds.RemoveFirst(); + } + else { - UserInvokeId = (uint)playData.Invoker.DatabaseId, + nextHid = CurrentID; + CurrentID++; + } + + var ale = new AudioLogEntry(nextHid, saveData.Resource) + { + UserInvokeId = (uint)saveData.OwnerDbId, Timestamp = Util.GetNow(), - ResourceTitle = resource.ResourceTitle, PlayCount = 1, }; - CurrentID++; return ale; } private void AppendToFile(AudioLogEntry logEntry, bool flush = true) { + fileStream.Seek(0, SeekOrigin.End); logEntry.FilePosIndex = fileStream.Position; var fileString = logEntry.ToFileString(); @@ -375,13 +402,11 @@ private void ReWriteToFile(AudioLogEntry logEntry) fileStream.Write(newLine, 0, newLine.Length); if (newLine.Length < curLine.Length) CleanLine(curLine.Length - newLine.Length); - fileStream.Seek(0, SeekOrigin.End); } else { byte[] filler = Enumerable.Repeat((byte)' ', curLine.Length).ToArray(); fileStream.Write(filler, 0, filler.Length); - fileStream.Seek(0, SeekOrigin.End); AppendToFile(logEntry, false); } fileStream.Flush(true); @@ -389,9 +414,9 @@ private void ReWriteToFile(AudioLogEntry logEntry) private void AddToMemoryIndex(AudioLogEntry logEntry) { - resIdToId.Add(logEntry.UniqueId, logEntry.Id); + resIdToId.Add(logEntry.AudioResource.UniqueId, logEntry.Id); idFilter.Add(logEntry.Id, logEntry); - titleFilter.Add(logEntry.ResourceTitle.ToLower(CultureInfo.InvariantCulture), logEntry); + titleFilter.Add(logEntry.AudioResource.ResourceTitle.ToLower(CultureInfo.InvariantCulture), logEntry); AutoAdd(userIdFilter, logEntry); timeFilter.Add(logEntry.Timestamp, logEntry); } @@ -399,9 +424,11 @@ private void AddToMemoryIndex(AudioLogEntry logEntry) private void RemoveFromFile(AudioLogEntry logEntry) { fileStream.Seek(logEntry.FilePosIndex, SeekOrigin.Begin); + fileReader.InvalidateBuffer(); byte[] curLine = FileEncoding.GetBytes(fileReader.ReadLine()); fileStream.Seek(logEntry.FilePosIndex, SeekOrigin.Begin); CleanLine(curLine.Length); + fileStream.Flush(true); } private void CleanLine(int length) @@ -412,11 +439,12 @@ private void CleanLine(int length) private void RemoveFromMemoryIndex(AudioLogEntry logEntry) { - resIdToId.Remove(logEntry.UniqueId); + resIdToId.Remove(logEntry.AudioResource.UniqueId); idFilter.Remove(logEntry.Id); - titleFilter.Remove(logEntry.ResourceTitle); + titleFilter.RemoveValue(logEntry); userIdFilter[logEntry.UserInvokeId].Remove(logEntry); timeFilter.Remove(logEntry.Timestamp); + unusedIds.AddLast(logEntry.Id); } private static void AutoAdd(IDictionary> dict, AudioLogEntry value) @@ -437,6 +465,7 @@ private void Clear() titleFilter.Clear(); userIdFilter.Clear(); timeFilter.Clear(); + unusedIds.Clear(); CurrentID = 0; } @@ -447,4 +476,18 @@ public void Dispose() Clear(); } } + + public class HistorySaveData + { + public AudioResource Resource { get; } + public ulong OwnerDbId { get; } + + public HistorySaveData(AudioResource resource, ulong ownerDbId) + { + if (resource == null) + throw new ArgumentNullException(nameof(resource)); + Resource = resource; + OwnerDbId = ownerDbId; + } + } } diff --git a/TS3AudioBot/History/HistoryManager.cs b/TS3AudioBot/History/HistoryManager.cs index f747af58..4a914429 100755 --- a/TS3AudioBot/History/HistoryManager.cs +++ b/TS3AudioBot/History/HistoryManager.cs @@ -20,8 +20,9 @@ namespace TS3AudioBot.History using System.Collections.Generic; using System.Linq; using Helper; + using ResourceFactories; - public class HistoryManager : MarshalByRefObject, IDisposable + public sealed class HistoryManager : IDisposable { private readonly object accessLock = new object(); private HistoryFile historyFile; @@ -33,13 +34,18 @@ public HistoryManager(HistoryManagerData hmd) { Formatter = new SmartHistoryFormatter(); historyFile = new HistoryFile(); + historyFile.ReuseUnusedIds = hmd.fillDeletedIds; historyFile.OpenFile(hmd.historyFile); } - public void LogAudioResource(object sender, PlayData playData) + public R LogAudioResource(HistorySaveData saveData) { lock (accessLock) - historyFile.Store(playData); + { + var entry = historyFile.Store(saveData); + if (entry != null) return entry; + else return "Entry could not be stored"; + } } public IEnumerable Search(SeachQuery query) @@ -83,10 +89,22 @@ public string SearchParsed(SeachQuery query) return Formatter.ProcessQuery(aleList, SmartHistoryFormatter.DefaultAleFormat); } - public AudioLogEntry GetEntryById(uint id) + public uint? FindEntryId(AudioResource resource) { lock (accessLock) - return historyFile.GetEntryById(id); + { + return historyFile.Contains(resource); + } + } + + public R GetEntryById(uint id) + { + lock (accessLock) + { + var entry = historyFile.GetEntryById(id); + if (entry != null) return entry; + else return "Could not find track with this id"; + } } public void RemoveEntry(AudioLogEntry ale) @@ -113,6 +131,48 @@ public void CleanHistoryFile() historyFile.CleanFile(); } + public void RemoveBrokenLinks(UserSession session) + { + lock (accessLock) + { + const int iterations = 3; + historyFile.BackupFile(); + var currentIter = historyFile.GetAll(); + + for (int i = 0; i < iterations; i++) + { + session.Write("Filter iteration " + i); + currentIter = FilterList(session, currentIter); + } + + foreach (var entry in currentIter) + { + historyFile.LogEntryRemove(entry); + session.Bot.PlaylistManager.AddToTrash(new PlaylistItem(entry.AudioResource)); + session.Write($"Removed: {entry.Id} - {entry.AudioResource.ResourceTitle}"); + } + } + } + + private List FilterList(UserSession session, IList list) + { + int userNotityCnt = 0; + var nextIter = new List(); + foreach (var entry in list) + { + var result = session.Bot.FactoryManager.Load(entry.AudioResource); + if (!result) + { + session.Write($"//DEBUG// ({entry.AudioResource.UniqueId}) Reason: {result.Message}"); + nextIter.Add(entry); + } + + if (++userNotityCnt % 100 == 0) + session.Write("Working" + new string('.', userNotityCnt / 100)); + } + return nextIter; + } + public void Dispose() { if (historyFile != null) @@ -133,5 +193,7 @@ public struct HistoryManagerData { [Info("the absolute or relative path to the history database file")] public string historyFile; + [Info("wether or not deleted history ids should be filled up with new songs", "true")] + public bool fillDeletedIds; } } \ No newline at end of file diff --git a/TS3AudioBot/History/SearchQuery.cs b/TS3AudioBot/History/SearchQuery.cs index 184eec6e..caf2569e 100644 --- a/TS3AudioBot/History/SearchQuery.cs +++ b/TS3AudioBot/History/SearchQuery.cs @@ -18,7 +18,7 @@ namespace TS3AudioBot.History { using System; - public class SeachQuery : MarshalByRefObject + public class SeachQuery { public string TitlePart { get; set; } public uint? UserId { get; set; } diff --git a/TS3AudioBot/History/SmartHistoryFormatter.cs b/TS3AudioBot/History/SmartHistoryFormatter.cs index ae53aabb..f2678d5d 100755 --- a/TS3AudioBot/History/SmartHistoryFormatter.cs +++ b/TS3AudioBot/History/SmartHistoryFormatter.cs @@ -17,11 +17,12 @@ namespace TS3AudioBot.History { using System; - using System.Text; - using System.Linq; using System.Collections.Generic; + using System.Linq; + using System.Text; + using TS3Client; - public class SmartHistoryFormatter : MarshalByRefObject, IHistoryFormatter + public class SmartHistoryFormatter : IHistoryFormatter { private const int TS3MAXLENGTH = 1024; // configurable constansts @@ -29,7 +30,7 @@ public class SmartHistoryFormatter : MarshalByRefObject, IHistoryFormatter private const int MinTokenLine = 40; private bool fairDistribute = true; // resulting constansts from configuration - private static readonly int LineBreakLen = TokenLength(LineBreak); + private static readonly int LineBreakLen = TS3String.TokenLength(LineBreak); private static readonly int UseableTokenLine = MinTokenLine - LineBreakLen; public string ProcessQuery(AudioLogEntry entry, Func format) @@ -43,7 +44,7 @@ public string ProcessQuery(IEnumerable entries, Func { string finStr = format(e); - return new Line { Value = finStr, TokenLength = TokenLength(finStr) }; + return new Line { Value = finStr, TokenLength = TS3String.TokenLength(finStr) }; }); //! entryLinesRev[0] is the most recent entry @@ -136,7 +137,7 @@ public string ProcessQuery(IEnumerable entries, Func string.Format("{0} ({2}): {1}", e.Id, e.ResourceTitle, e.UserInvokeId, e.PlayCount, e.Timestamp); + => string.Format("{0} ({2}): {1}", e.Id, e.AudioResource.ResourceTitle, e.UserInvokeId, e.PlayCount, e.Timestamp); /// Trims a string to have the given token count at max. /// The string to substring from the left side. @@ -147,28 +148,13 @@ private static string SubstringToken(string value, int token) int tokens = 0; for (int i = 0; i < value.Length; i++) { - int addToken = IsDoubleChar(value[i]) ? 2 : 1; + int addToken = TS3String.IsDoubleChar(value[i]) ? 2 : 1; if (tokens + addToken > token) return value.Substring(0, i); else tokens += addToken; } return value; } - private static int TokenLength(string str) => str.Length + str.Count(IsDoubleChar); - - private static bool IsDoubleChar(char c) - { - return c == '\\' || - c == '/' || - c == ' ' || - c == '|' || - c == '\f' || - c == '\n' || - c == '\r' || - c == '\t' || - c == '\v'; - } - class Line { public string Value { get; set; } = null; diff --git a/TS3AudioBot/IPlayerConnection.cs b/TS3AudioBot/IPlayerConnection.cs index f8f70ce9..7b72931b 100644 --- a/TS3AudioBot/IPlayerConnection.cs +++ b/TS3AudioBot/IPlayerConnection.cs @@ -17,23 +17,26 @@ namespace TS3AudioBot { using System; + using Helper; public interface IPlayerConnection : IDisposable { void Initialize(); - bool SupportsEndCallback { get; } event EventHandler OnSongEnd; - int Volume { get; set; } - TimeSpan Position { get; set; } - bool Repeated { get; set; } - bool Pause { get; set; } - TimeSpan Length { get; } - bool IsPlaying { get; } + R GetVolume(); + void SetVolume(int value); + R GetPosition(); + void SetPosition(TimeSpan value); + R IsRepeated(); + void SetRepeated(bool value); + R IsPaused(); + void SetPaused(bool value); + R GetLength(); + R IsPlaying(); - // TODO check change in uri ?? - void AudioStart(string url); - void AudioStop(); + R AudioStart(string url); + R AudioStop(); } } diff --git a/TS3AudioBot/ITeamspeakControl.cs b/TS3AudioBot/ITeamspeakControl.cs new file mode 100644 index 00000000..9bde5c92 --- /dev/null +++ b/TS3AudioBot/ITeamspeakControl.cs @@ -0,0 +1,35 @@ +namespace TS3AudioBot +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Text; + using System.Threading.Tasks; + using Helper; + using TS3Client; + using TS3Client.Messages; + + public interface ITeamspeakControl : IDisposable + { + event EventHandler OnMessageReceived; + event EventHandler OnClientConnect; + event EventHandler OnClientDisconnect; + + void Connect(); + void EnterEventLoop(); + + void SendMessage(string message, ushort clientId); + void SendGlobalMessage(string message); + void KickClientFromServer(ushort clientId); + void KickClientFromChannel(ushort clientId); + void ChangeDescription(string description); + + ClientData GetClientById(ushort id); + ClientData GetClientByName(string name); + ClientData GetSelf(); + + void RefreshClientBuffer(bool force); + ulong[] GetClientServerGroups(ClientData client); + string GetNameByDbId(ulong clientDbId); + } +} diff --git a/TS3AudioBot/MainBot.cs b/TS3AudioBot/MainBot.cs index fee49710..3dace83a 100755 --- a/TS3AudioBot/MainBot.cs +++ b/TS3AudioBot/MainBot.cs @@ -28,14 +28,14 @@ namespace TS3AudioBot using Helper; using History; using ResourceFactories; - using static CommandRights; + using WebInterface; + + using TS3Client; + using TS3Client.Messages; - using TS3Query; - using TS3Query.Messages; + using static CommandRights; - // Todo: - // - implement history missing features - public sealed class MainBot : MarshalByRefObject, IDisposable + public sealed class MainBot : IDisposable { static void Main(string[] args) { @@ -47,7 +47,7 @@ static void Main(string[] args) Exception ex = e.ExceptionObject as Exception; while (ex != null) { - Log.Write(Log.Level.Error, "MSG: {0}\nSTACK:{1}", ex.Message, ex.StackTrace); + Log.Write(Log.Level.Error, "MSG: {0}\nTYPE:{1}\nSTACK:{2}", ex.Message, ex.GetType().Name, ex.StackTrace); ex = ex.InnerException; } bot?.Dispose(); @@ -70,11 +70,14 @@ static void Main(string[] args) internal PluginManager PluginManager { get; private set; } public CommandManager CommandManager { get; private set; } public AudioFramework AudioFramework { get; private set; } + public PlaylistManager PlaylistManager { get; private set; } public BobController BobController { get; private set; } - public QueryConnection QueryConnection { get; private set; } + public ITeamspeakControl QueryConnection { get; private set; } public SessionManager SessionManager { get; private set; } public HistoryManager HistoryManager { get; private set; } public ResourceFactoryManager FactoryManager { get; private set; } + public WebDisplay WebInterface { get; private set; } + public PlayManager PlayManager { get; private set; } public bool QuizMode { get; set; } @@ -108,13 +111,14 @@ private bool InitializeBot() { // Read Config File const string configFilePath = "configTS3AudioBot.cfg"; - ConfigFile cfgFile = ConfigFile.Open(configFilePath) ?? ConfigFile.Create(configFilePath) ?? ConfigFile.GetDummy(); + ConfigFile cfgFile = ConfigFile.Open(configFilePath) ?? ConfigFile.Create(configFilePath) ?? ConfigFile.CreateDummy(); var afd = cfgFile.GetDataStruct(typeof(AudioFramework), true); var bcd = cfgFile.GetDataStruct(typeof(BobController), true); var qcd = cfgFile.GetDataStruct(typeof(QueryConnection), true); var hmd = cfgFile.GetDataStruct(typeof(HistoryManager), true); var pmd = cfgFile.GetDataStruct(typeof(PluginManager), true); var pld = cfgFile.GetDataStruct(typeof(PlaylistManager), true); + var yfd = cfgFile.GetDataStruct(typeof(YoutubeFactory), true); mainBotData = cfgFile.GetDataStruct(typeof(MainBot), true); cfgFile.Close(); @@ -152,30 +156,38 @@ private bool InitializeBot() Log.Write(Log.Level.Info, "[============ Initializing Modules ============]"); QueryConnection = new QueryConnection(qcd); - var playlistManager = new PlaylistManager(pld); + PlaylistManager = new PlaylistManager(pld); BobController = new BobController(bcd, QueryConnection); - // old: new VLCConnection(afd.vlcLocation); - // new: BobController - AudioFramework = new AudioFramework(afd, BobController, playlistManager); + AudioFramework = new AudioFramework(afd, BobController); SessionManager = new SessionManager(); HistoryManager = new HistoryManager(hmd); PluginManager = new PluginManager(this, pmd); + PlayManager = new PlayManager(this); + WebInterface = new WebDisplay(this); Log.Write(Log.Level.Info, "[=========== Initializing Factories ===========]"); - FactoryManager = new ResourceFactoryManager(AudioFramework); - FactoryManager.DefaultFactorty = new MediaFactory(); - FactoryManager.AddFactory(new YoutubeFactory()); + FactoryManager = new ResourceFactoryManager(); + var mediaFactory = new MediaFactory(); + FactoryManager.AddFactory(mediaFactory); + var youtubeFactory = new YoutubeFactory(yfd); + FactoryManager.AddFactory(youtubeFactory); FactoryManager.AddFactory(new SoundcloudFactory()); FactoryManager.AddFactory(new TwitchFactory()); + FactoryManager.DefaultFactorty = mediaFactory; + CommandManager.RegisterCommand(FactoryManager.CommandNode, "from"); + + PlaylistManager.AddFactory(youtubeFactory); + PlaylistManager.AddFactory(mediaFactory); + CommandManager.RegisterCommand(PlaylistManager.CommandNode, "list from"); Log.Write(Log.Level.Info, "[=========== Registering callbacks ============]"); - // Inform our HistoryManager when a new resource started successfully - AudioFramework.OnResourceStarted += HistoryManager.LogAudioResource; + AudioFramework.OnResourceStopped += PlayManager.SongStoppedHook; // Inform the BobClient on start/stop - AudioFramework.OnResourceStarted += BobController.OnResourceStarted; - AudioFramework.OnResourceStopped += BobController.OnResourceStopped; + PlayManager.AfterResourceStarted += BobController.OnResourceStarted; + PlayManager.AfterResourceStopped += BobController.OnPlayStopped; // In own favor update the own status text to the current song title - AudioFramework.OnResourceStarted += SongUpdateEvent; + PlayManager.AfterResourceStarted += SongUpdateEvent; + PlayManager.AfterResourceStopped += SongStopEvent; // Register callback for all messages happening QueryConnection.OnMessageReceived += TextCallback; // Register callback to remove open private sessions, when user disconnects @@ -183,11 +195,10 @@ private bool InitializeBot() Log.Write(Log.Level.Info, "[================= Finalizing =================]"); - // Create a default session for all users in all chat - SessionManager.DefaultSession = new PublicSession(this); + WebInterface.StartServerAsync(); // Connect the query after everyting is set up try { QueryConnection.Connect(); } - catch (QueryCommandException qcex) + catch (TS3CommandException qcex) { Log.Write(Log.Level.Error, "There is either a problem with your connection configuration, or the query has not all permissions it needs. ({0})", qcex); return false; @@ -200,7 +211,7 @@ private bool InitializeBot() private void Run() { Thread.CurrentThread.Name = "Main/Eventloop"; - QueryConnection.tsClient.EnterEventLoop(); + QueryConnection.EnterEventLoop(); } #region COMMAND EXECUTING & CHAINING @@ -210,76 +221,74 @@ private void TextCallback(object sender, TextMessage textMessage) Log.Write(Log.Level.Debug, "MB Got message from {0}: {1}", textMessage.InvokerName, textMessage.Message); textMessage.Message = textMessage.Message.TrimStart(new[] { ' ' }); - if (!textMessage.Message.StartsWith("!")) + if (!textMessage.Message.StartsWith("!", StringComparison.Ordinal)) return; BobController.HasUpdate(); QueryConnection.RefreshClientBuffer(true); // get the current session - BotSession session = SessionManager.GetSession(textMessage.Target, textMessage.InvokerId); - if (textMessage.Target == MessageTarget.Private && session == SessionManager.DefaultSession) + var result = SessionManager.GetSession(textMessage.InvokerId); + if (!result) + result = SessionManager.CreateSession(this, textMessage.InvokerId); + if (!result) { - Log.Write(Log.Level.Debug, "MB User {0} created auto-private session with the bot", textMessage.InvokerName); - try - { - session = SessionManager.CreateSession(this, textMessage.InvokerId); - } - catch (SessionManagerException smex) - { - Log.Write(Log.Level.Error, smex.ToString()); - return; - } + Log.Write(Log.Level.Error, result.Message); + return; } - var isAdmin = new Lazy(() => HasInvokerAdminRights(textMessage)); - var execInfo = new ExecutionInformation(session, textMessage, isAdmin); - - // check if the user has an open request - if (session.ResponseProcessor != null) + UserSession session = result.Value; + using (session.GetToken(textMessage.Target == MessageTarget.Private)) { - if (session.ResponseProcessor(execInfo)) - { - session.ClearResponse(); - return; - } - } + var execInfo = new ExecutionInformation(session, textMessage, + new Lazy(() => HasInvokerAdminRights(textMessage.InvokerId))); - // parse (and execute) the command - ASTNode parsedAst = CommandParser.ParseCommandRequest(textMessage.Message); - if (parsedAst.Type == ASTType.Error) - { - PrintAstError(session, (ASTError)parsedAst); - } - else - { - var command = CommandManager.CommandSystem.AstToCommandResult(parsedAst); - - try + // check if the user has an open request + if (session.ResponseProcessor != null) { - var res = command.Execute(execInfo, Enumerable.Empty(), - new[] { CommandResultType.String, CommandResultType.Empty }); - if (res.ResultType == CommandResultType.String) + if (session.ResponseProcessor(execInfo)) { - var sRes = (StringCommandResult)res; - // Write result to user - if (!string.IsNullOrEmpty(sRes.Content)) - session.Write(sRes.Content); + session.ClearResponse(); + return; } } - catch (CommandException ex) + + // parse (and execute) the command + ASTNode parsedAst = CommandParser.ParseCommandRequest(textMessage.Message); + if (parsedAst.Type == ASTType.Error) { - session.Write("Error: " + ex.Message); + PrintAstError(session, (ASTError)parsedAst); } - catch (Exception ex) + else { - session.Write("An unexpected error occured: " + ex.Message); - Log.Write(Log.Level.Error, "MB Unexpected command error: ", ex); + var command = CommandManager.CommandSystem.AstToCommandResult(parsedAst); + + try + { + var res = command.Execute(execInfo, Enumerable.Empty(), + new[] { CommandResultType.String, CommandResultType.Empty }); + if (res.ResultType == CommandResultType.String) + { + var sRes = (StringCommandResult)res; + // Write result to user + if (!string.IsNullOrEmpty(sRes.Content)) + session.Write(sRes.Content); + } + } + catch (CommandException ex) + { + session.Write("Error: " + ex.Message); + } + catch (Exception ex) + { + Log.Write(Log.Level.Error, "MB Unexpected command error: {0}", ex); + session.Write("An unexpected error occured: " + ex.Message); + } } } } - private void PrintAstError(BotSession session, ASTError asterror) + private void PrintAstError(UserSession session, ASTError asterror) { StringBuilder strb = new StringBuilder(); strb.AppendLine(); @@ -287,13 +296,13 @@ private void PrintAstError(BotSession session, ASTError asterror) session.Write(strb.ToString()); } - private bool HasInvokerAdminRights(TextMessage textMessage) + private bool HasInvokerAdminRights(ushort clientId) { Log.Write(Log.Level.Debug, "AdminCheck called!"); - ClientData client = QueryConnection.GetClientById(textMessage.InvokerId); + ClientData client = QueryConnection.GetClientById(clientId); if (client == null) return false; - int[] clientSgIds = QueryConnection.GetClientServerGroups(client); + var clientSgIds = QueryConnection.GetClientServerGroups(client); return clientSgIds.Contains(mainBotData.adminGroupId); } @@ -310,14 +319,13 @@ private bool HasInvokerAdminRights(TextMessage textMessage) [Usage("", "Any link that is also recognized by !play")] public string CommandAdd(ExecutionInformation info, string parameter) { - ClientData client = QueryConnection.GetClientById(info.TextMessage.InvokerId); - return FactoryManager.LoadAndPlay(new PlayData(info.Session, client, parameter, true)); + return PlayManager.Enqueue(info.Session.Client, parameter); } [Command(Private, "clear", "Removes all songs from the current playlist.")] public void CommandClear() { - AudioFramework.Clear(); + PlaylistManager.ClearFreelist(); } [Command(AnyVisibility, "eval", "Executes a given command or string")] @@ -346,7 +354,7 @@ public ICommandResult CommandEval(ExecutionInformation info, IEnumerable", "A user which is currently logged in to the server")] - public string CommandGetUserId(ExecutionInformation info, string parameter) + public string CommandGetUserId(string parameter) { ClientData client = QueryConnection.GetClientByName(parameter); if (client == null) @@ -426,21 +434,39 @@ public string CommandHelp(ExecutionInformation info, params string[] parameter) } } - [Command(Admin, "history delete", " Removes the entry with from the history")] - public string CommandHistoryDelete(ExecutionInformation info, uint id) + [Command(Private, "history add", " Adds the song with to the queue")] + public string CommandHistoryQueue(ExecutionInformation info, uint id) { - AudioLogEntry ale = HistoryManager.GetEntryById(id); - if (ale == null) - return "Could not find track with this id"; - info.Session.SetResponse(ResponseHistoryDelete, ale); - return $"Do you really want to delete the entry with the id {id}? !(yes|no)"; + return PlayManager.Enqueue(info.Session.Client, id); } [Command(Admin, "history clean", "Cleans up the history file for better startup performance.")] public string CommandHistoryClean(ExecutionInformation info) { info.Session.SetResponse(ResponseHistoryClean, null); - return $"Dou want to clean the history file now? This might take a while and make the bot unresponsive in meanwhile. !(yes|no)"; + return $"Do want to clean the history file now? " + + "This might take a while and make the bot unresponsive in meanwhile. !(yes|no)"; + } + + [Command(Admin, "history clean removedefective", "Cleans up the history file for better startup performance.")] + public string CommandHistoryCleanRemove(ExecutionInformation info) + { + info.Session.SetResponse(ResponseHistoryClean, "removedefective"); + return $"Do want to remove all defective links file now? " + + "This might(will!) take a while and make the bot unresponsive in meanwhile. !(yes|no)"; + } + + [Command(Admin, "history delete", " Removes the entry with from the history")] + public string CommandHistoryDelete(ExecutionInformation info, uint id) + { + var result = HistoryManager.GetEntryById(id); + if (!result) + return result.Message; + info.Session.SetResponse(ResponseHistoryDelete, result.Value); + string name = result.Value.AudioResource.ResourceTitle; + if (name.Length > 100) + name = name.Substring(100) + "..."; + return $"Do you really want to delete the entry \"{name}\"\nwith the id {id}? !(yes|no)"; } [Command(Private, "history from", "Gets the last songs from the user with the given ")] @@ -462,10 +488,10 @@ public string CommandHistoryFrom(uint userDbId, int? amount) [Command(Private, "history id", " Displays all saved informations about the song with ")] public string CommandHistoryId(uint id) { - var ale = HistoryManager.GetEntryById(id); - if (ale == null) - return "Could not find track with this id"; - return HistoryManager.Formatter.ProcessQuery(ale, SmartHistoryFormatter.DefaultAleFormat); + var result = HistoryManager.GetEntryById(id); + if (!result) + return result.Message; + return HistoryManager.Formatter.ProcessQuery(result.Value, SmartHistoryFormatter.DefaultAleFormat); } [Command(Private, "history id", "(last|next) Gets the highest|next song id")] @@ -494,8 +520,7 @@ public string CommandHistoryLast(ExecutionInformation info, int? amount) var ale = HistoryManager.Search(new SeachQuery { MaxResults = 1 }).FirstOrDefault(); if (ale != null) { - ClientData client = QueryConnection.GetClientById(info.TextMessage.InvokerId); - return FactoryManager.RestoreAndPlay(ale, new PlayData(info.Session, client, null, false)); + return PlayManager.Play(info.Session.Client, ale.AudioResource); } else return "There is no song in the history"; } @@ -504,34 +529,20 @@ public string CommandHistoryLast(ExecutionInformation info, int? amount) [Command(Private, "history play", " Playes the song with ")] public string CommandHistoryPlay(ExecutionInformation info, uint id) { - var ale = HistoryManager.GetEntryById(id); - if (ale == null) - return "Could not find track with this id"; - ClientData client = QueryConnection.GetClientById(info.TextMessage.InvokerId); - return FactoryManager.RestoreAndPlay(ale, new PlayData(info.Session, client, null, false)); - } - - [Command(Private, "history queue", " Adds the song with to the queue")] - public string CommandHistoryQueue(ExecutionInformation info, uint id) - { - var ale = HistoryManager.GetEntryById(id); - if (ale == null) - return "Could not find track with this id"; - ClientData client = QueryConnection.GetClientById(info.TextMessage.InvokerId); - return FactoryManager.RestoreAndPlay(ale, new PlayData(info.Session, client, null, true)); + return PlayManager.Play(info.Session.Client, id); } [Command(Admin, "history rename", " Sets the name of the song with to ")] public string CommandHistoryRename(uint id, string newName) { - var ale = HistoryManager.GetEntryById(id); - if (ale == null) - return "Could not find track with this id"; + var result = HistoryManager.GetEntryById(id); + if (!result) + return result.Message; if (string.IsNullOrWhiteSpace(newName)) return "The new name must not be empty or only whitespaces"; - HistoryManager.RenameEntry(ale, newName); + HistoryManager.RenameEntry(result.Value, newName); return null; } @@ -546,13 +557,13 @@ public string CommandHistoryTill(DateTime time) public string CommandHistoryTill(string time) { DateTime tillTime; - switch (time.ToLower()) + switch (time.ToLower(CultureInfo.InvariantCulture)) { - case "hour": tillTime = DateTime.Now.AddHours(-1); break; - case "today": tillTime = DateTime.Today; break; - case "yesterday": tillTime = DateTime.Today.AddDays(-1); break; - case "week": tillTime = DateTime.Today.AddDays(-7); break; - default: return "Not recognized time desciption."; + case "hour": tillTime = DateTime.Now.AddHours(-1); break; + case "today": tillTime = DateTime.Today; break; + case "yesterday": tillTime = DateTime.Today.AddDays(-1); break; + case "week": tillTime = DateTime.Today.AddDays(-7); break; + default: return "Not recognized time desciption."; } var query = new SeachQuery { LastInvokedAfter = tillTime }; return HistoryManager.SearchParsed(query); @@ -580,13 +591,13 @@ public ICommandResult CommandIf(ExecutionInformation info, IEnumerable Func comparer; switch (cmp) { - case "<": comparer = (a, b) => a < b; break; - case ">": comparer = (a, b) => a > b; break; - case "<=": comparer = (a, b) => a <= b; break; - case ">=": comparer = (a, b) => a >= b; break; - case "==": comparer = (a, b) => a == b; break; - case "!=": comparer = (a, b) => a != b; break; - default: throw new CommandException("Unknown comparison operator"); + case "<": comparer = (a, b) => a < b; break; + case ">": comparer = (a, b) => a > b; break; + case "<=": comparer = (a, b) => a <= b; break; + case ">=": comparer = (a, b) => a >= b; break; + case "==": comparer = (a, b) => a == b; break; + case "!=": comparer = (a, b) => a != b; break; + default: throw new CommandException("Unknown comparison operator"); } double d0, d1; @@ -596,7 +607,7 @@ public ICommandResult CommandIf(ExecutionInformation info, IEnumerable && double.TryParse(arg1, NumberStyles.Number, CultureInfo.InvariantCulture, out d1)) cmpResult = comparer(d0, d1); else - cmpResult = comparer(arg0.CompareTo(arg1), 0); + cmpResult = comparer(string.CompareOrdinal(arg0, arg1), 0); // If branch if (cmpResult) @@ -624,7 +635,7 @@ public string CommandKickme(ExecutionInformation info, string parameter) QueryConnection.KickClientFromServer(info.TextMessage.InvokerId); return null; } - catch (QueryCommandException ex) + catch (TS3CommandException ex) { Log.Write(Log.Level.Info, "Could not kick: {0}", ex); return "I'm not strong enough, master!"; @@ -634,55 +645,288 @@ public string CommandKickme(ExecutionInformation info, string parameter) [Command(Private, "link", "Gets a link to the origin of the current song.")] public string CommandLink(ExecutionInformation info) { - if (AudioFramework.CurrentPlayData == null) + if (PlayManager.CurrentPlayData == null) return "There is nothing on right now..."; - else if (QuizMode && AudioFramework.CurrentPlayData.Invoker.ClientId != info.TextMessage.InvokerId) + else if (QuizMode && PlayManager.CurrentPlayData.Invoker.ClientId != info.TextMessage.InvokerId) return "Sorry, you have to guess!"; else - return FactoryManager.RestoreLink(AudioFramework.CurrentPlayData); + return FactoryManager.RestoreLink(PlayManager.CurrentPlayData.ResourceData); + } + + [Command(Private, "list add")] + public string CommandListAdd(ExecutionInformation info, string link) + { + var plist = AutoGetPlaylist(info.Session); + var result = FactoryManager.Load(link); + if (!result) + return result.Message; + plist.AddItem(new PlaylistItem(result.Value.BaseData, new MetaData() { ResourceOwnerDbId = info.Session.ClientCached.DatabaseId })); + return null; + } + + [Command(Private, "list add")] + public string CommandListAdd(ExecutionInformation info, uint hid) + { + var plist = AutoGetPlaylist(info.Session); + + if (hid < 0 || !HistoryManager.GetEntryById(hid)) + return "History entry not found"; + + plist.AddItem(new PlaylistItem(hid, new MetaData() { ResourceOwnerDbId = info.Session.ClientCached.DatabaseId })); + return null; + } + + [Command(Private, "list clear")] + public void CommandListClear(ExecutionInformation info) + { + var plist = AutoGetPlaylist(info.Session); + plist.Clear(); + } + + [Command(Private, "list delete")] + public string CommandListDelete(ExecutionInformation info, string name) + { + var hresult = PlaylistManager.LoadPlaylist(name, true); + if (!hresult) + { + info.Session.SetResponse(ResponseListDelete, name); + return $"Do you really wan to delete the playlist \"{name}\" (error:{hresult.Message})"; + } + else + { + if (hresult.Value.CreatorDbId.HasValue + && hresult.Value.CreatorDbId.Value != info.Session.ClientCached.DatabaseId + && !info.IsAdmin) + return "You are not allowed to delete others playlists"; + + info.Session.SetResponse(ResponseListDelete, name); + return $"Do you really wan to delete the playlist \"{name}\""; + } + } + + [Command(Private, "list get")] + public string CommandListGet(ExecutionInformation info, string link) + { + var result = info.Session.Bot.PlaylistManager.LoadPlaylistFrom(link); + + if (!result) + return result; + + result.Value.CreatorDbId = info.Session.ClientCached.DatabaseId; + info.Session.Set(result.Value); + return "Ok"; + } + + [Command(Private, "list item move")] + public string CommandListMove(ExecutionInformation info, int from, int to) + { + var plist = AutoGetPlaylist(info.Session); + + if (from < 0 || from >= plist.Count + || to < 0 || to >= plist.Count) + return "Index must be within playlist length"; + + if (from == to) + return null; + + var plitem = plist.GetResource(from); + plist.RemoveItemAt(from); + plist.InsertItem(plitem, Math.Min(to, plist.Count)); + return null; + } + + [Command(Private, "list item delete")] + public string CommandListRemove(ExecutionInformation info, int index) + { + var plist = AutoGetPlaylist(info.Session); + + if (index < 0 || index >= plist.Count) + return "Index must be within playlist length"; + + var deletedItem = plist.GetResource(index); + plist.RemoveItemAt(index); + return "Removed: " + deletedItem.DisplayString; + } + + // add list item rename + + [Command(Private, "list list")] + [RequiredParameters(0)] + public string CommandListList(ExecutionInformation info, string pattern) + { + var files = PlaylistManager.GetAvailablePlaylists(pattern); + if (!files.Any()) + return "No saved playlists"; + + var strb = new StringBuilder(); + int tokenLen = 0; + foreach (var file in files) + { + int newTokenLen = tokenLen + TS3String.TokenLength(file) + 3; + if (newTokenLen < 1024) + { + strb.Append(file).Append(", "); + tokenLen = newTokenLen; + } + else + break; + } + + if (strb.Length > 2) strb.Length -= 2; + return strb.ToString(); + } + + [Command(Private, "list load")] + public string CommandListLoad(ExecutionInformation info, string name) + { + Playlist loadList = AutoGetPlaylist(info.Session); + + var result = PlaylistManager.LoadPlaylist(name); + if (!result) + return result.Message; + else + { + loadList.Clear(); + loadList.AddRange(result.Value.AsEnumerable()); + loadList.Name = result.Value.Name; + return $"Loaded: \"{name}\" with {loadList.Count} songs"; + } + } + + [Command(Private, "list merge")] + public string CommandListMerge(ExecutionInformation info, string name) + { + var plist = AutoGetPlaylist(info.Session); + + var lresult = PlaylistManager.LoadPlaylist(name); + if (!lresult) + return "The other playlist could not be found"; + + plist.AddRange(lresult.Value.AsEnumerable()); + return null; + } + + [Command(Private, "list name")] + public string CommandListName(ExecutionInformation info, string name) + { + var plist = AutoGetPlaylist(info.Session); + + if (string.IsNullOrEmpty(name)) + return plist.Name; + + var result = PlaylistManager.IsNameValid(name); + if (!result) + return result; + + plist.Name = name; + return null; + } + + [Command(Private, "list play")] + [RequiredParameters(0)] + public string CommandListPlay(ExecutionInformation info, int? index) + { + var plist = AutoGetPlaylist(info.Session); + + if (!index.HasValue + || (index.HasValue && index.Value >= 0 && index.Value < plist.Count)) + { + PlaylistManager.PlayFreelist(plist); + PlaylistManager.Index = index ?? 0; + } + else return "Invalid starting index"; + + PlaylistItem item = PlaylistManager.Current(); + if (item != null) + { + PlayManager.Play(info.Session.Client, item); + return null; + } + else return "Nothing to play..."; + } + + [Command(Private, "list save")] + [RequiredParameters(0)] + public string CommandListSave(ExecutionInformation info, string optNewName) + { + var plist = AutoGetPlaylist(info.Session); + if (!string.IsNullOrEmpty(optNewName)) + { + var result = PlaylistManager.IsNameValid(optNewName); + if (!result) + return result; + plist.Name = optNewName; + } + + var sresult = PlaylistManager.SavePlaylist(plist); + if (sresult) + return "Ok"; + else + return sresult.Message; } - [Command(Private, "loop", "Sets whether or not to loop the entire playlist.")] - [Usage("(on|off)]", "on or off")] + [Command(Private, "list show")] + [RequiredParameters(0)] + public string CommandListShow(ExecutionInformation info, int? offset) => CommandListShow(info, null, offset); + + [Command(Private, "list show")] + [RequiredParameters(0)] + public string CommandListShow(ExecutionInformation info, string name, int? offset) + { + Playlist plist; + if (!string.IsNullOrEmpty(name)) + { + var result = PlaylistManager.LoadPlaylist(name); + if (!result) + return result.Message; + plist = result.Value; + } + else + plist = AutoGetPlaylist(info.Session); + + var strb = new StringBuilder(); + strb.Append($"Playlist: \"").Append(plist.Name).Append("\" with ").Append(plist.Count).AppendLine(" songs."); + int from = Math.Max(offset ?? 0, 0); + foreach (var plitem in plist.AsEnumerable().Skip(from).Take(10)) + strb.Append(from++).Append(": ").AppendLine(plitem.DisplayString); + + return strb.ToString(); + } + + [Command(Private, "loop", "Gets or sets whether or not to loop the entire playlist.")] + [Usage("[(on|off)]", "on or off")] [RequiredParameters(0)] public string CommandLoop(ExecutionInformation info, string parameter) { if (string.IsNullOrEmpty(parameter)) - return "Loop is " + (AudioFramework.Loop ? "on" : "off"); + return "Loop is " + (PlaylistManager.Loop ? "on" : "off"); else if (parameter == "on") - AudioFramework.Loop = true; + PlaylistManager.Loop = true; else if (parameter == "off") - AudioFramework.Loop = false; + PlaylistManager.Loop = false; else return CommandHelp(info, "loop"); return null; } - [Command(Private, "media", "Plays any local or online media file.")] - public string CommandMedia(ExecutionInformation info, string parameter) - { - ClientData client = QueryConnection.GetClientById(info.TextMessage.InvokerId); - return FactoryManager.LoadAndPlay(AudioType.MediaLink, new PlayData(info.Session, client, parameter, false)); - } - [Command(Private, "next", "Plays the next song in the playlist.")] - public void CommandNext() + public string CommandNext(ExecutionInformation info) { - AudioFramework.Next(); + return PlayManager.Next(info.Session.Client); } [Command(Public, "pm", "Requests a private session with the ServerBot so you can invoke private commands.")] - public void CommandPM(ExecutionInformation info) + public string CommandPM(ExecutionInformation info) { - BotSession ownSession = SessionManager.CreateSession(this, info.TextMessage.InvokerId); - ownSession.Write("Hi " + info.TextMessage.InvokerName); + info.Session.IsPrivate = true; + return "Hi " + info.TextMessage.InvokerName; } [Command(Admin, "parse", "Displays the AST of the requested command.")] [Usage("", "The comand to be parsed")] public string CommandParse(string parameter) { - if (!parameter.TrimStart().StartsWith("!")) + if (!parameter.TrimStart().StartsWith("!", StringComparison.Ordinal)) return "This is not a command"; try { @@ -716,8 +960,7 @@ public string CommandPlay(ExecutionInformation info, string parameter) } else { - ClientData client = QueryConnection.GetClientById(info.TextMessage.InvokerId); - return FactoryManager.LoadAndPlay(new PlayData(info.Session, client, parameter, false)); + return PlayManager.Play(info.Session.Client, parameter); } } @@ -740,9 +983,9 @@ public string CommandPluginLoad(string identifier) } [Command(Private, "previous", "Plays the previous song in the playlist.")] - public void CommandPrevious() + public string CommandPrevious(ExecutionInformation info) { - AudioFramework.Previous(); + return PlayManager.Previous(info.Session.Client); } [Command(AnyVisibility, "print", "Lets you format multiple parameter to one.")] @@ -756,34 +999,26 @@ public string CommandPrint(params string[] parameter) } [Command(Admin, "quit", "Closes the TS3AudioBot application.")] - public string CommandQuit(ExecutionInformation info) - { - info.Session.SetResponse(ResponseQuit, null); - return "Do you really want to quit? !(yes|no)"; - } - - [Command(Admin, "quit force", "Closes the TS3AudioBot application.")] - public void CommandQuitForce(ExecutionInformation info) - { - info.Session.Write("Goodbye!"); - Dispose(); - Log.Write(Log.Level.Info, "Exiting..."); - } - - [Command(Admin, "quit last", "Disconnects the Bob when noone is on the server anymore.")] - public void CommandQuitLast(ExecutionInformation info) - { - throw new NotImplementedException(); - } - - [Command(Admin, "quit reset", "Discards any \"quit last\" request.")] - public void CommandQuitReset(ExecutionInformation info) + [RequiredParameters(0)] + public string CommandQuit(ExecutionInformation info, string param) { - throw new NotImplementedException(); + switch (param) + { + case "force": + QueryConnection.OnMessageReceived -= TextCallback; + info.Session.Write("Goodbye!"); + Dispose(); + Log.Write(Log.Level.Info, "Exiting..."); + return null; + + default: + info.Session.SetResponse(ResponseQuit, null); + return "Do you really want to quit? !(yes|no)"; + } } [Command(Public, "quiz", "Enable to hide the songnames and let your friends guess the title.")] - [Usage("(on|off)]", "on or off")] + [Usage("[(on|off)]", "on or off")] [RequiredParameters(0)] public string CommandQuiz(ExecutionInformation info, string parameter) { @@ -799,15 +1034,38 @@ public string CommandQuiz(ExecutionInformation info, string parameter) if (info.Session.IsPrivate) return "No cheatig! Everybody has to see it!"; QuizMode = false; - QueryConnection.ChangeDescription(AudioFramework.CurrentPlayData.Resource.ResourceTitle); + QueryConnection.ChangeDescription(PlayManager.CurrentPlayData.ResourceData.ResourceTitle); } else CommandHelp(info, "quiz"); return null; } - [Command(Private, "repeat", "Sets whether or not to loop a single song.")] - [Usage("(on|off)]", "on or off")] + [Command(Private, "random", "Gets whether or not to play playlists in random order.")] + public string CommandRandom() => "Random is " + (PlaylistManager.Random ? "on" : "off"); + [Command(Private, "random on", "Enables random playlist playback")] + public void CommandRandomOn() => PlaylistManager.Random = true; + [Command(Private, "random off", "Disables random playlist playback")] + public void CommandRandomOff() => PlaylistManager.Random = false; + [Command(Private, "random seed", "Gets the unique seed for a certain playback order")] + public string CommandRandomSeed() + { + string seed = Util.FromSeed(PlaylistManager.Seed); + return string.IsNullOrEmpty(seed) ? "" : seed; + } + [Command(Private, "random seed", "Sets the unique seed for a certain playback order")] + public string CommandRandomSeed(string newSeed) + { + if (newSeed.Any(c => !char.IsLetter(c))) + return "Only letter allowed"; + PlaylistManager.Seed = Util.ToSeed(newSeed.ToLowerInvariant()); + return null; + } + [Command(Private, "random seed", "Sets the unique seed for a certain playback order")] + public void CommandRandomSeed(int newSeed) => PlaylistManager.Seed = newSeed; + + [Command(Private, "repeat", "Gets or sets whether or not to loop a single song.")] + [Usage("[(on|off)]", "on or off")] [RequiredParameters(0)] public string CommandRepeat(ExecutionInformation info, string parameter) { @@ -829,12 +1087,12 @@ public string CommandRepeat(ExecutionInformation info, string parameter) [RequiredParameters(0)] public string CommandRng(int? first, int? second) { - if (second != null) - return Util.RngInstance.Next(first.Value, second.Value).ToString(); - else if (first != null) - return Util.RngInstance.Next(first.Value).ToString(); + if (second.HasValue) + return Util.RngInstance.Next(first.Value, second.Value).ToString(CultureInfo.InvariantCulture); + else if (first.HasValue) + return Util.RngInstance.Next(first.Value).ToString(CultureInfo.InvariantCulture); else - return Util.RngInstance.Next().ToString(); + return Util.RngInstance.Next().ToString(CultureInfo.InvariantCulture); } [Command(Private, "seek", "Jumps to a timemark within the current song.")] @@ -868,27 +1126,22 @@ public string CommandSeek(ExecutionInformation info, string parameter) if (!parsed) return CommandHelp(info, "seek"); - if (!AudioFramework.Seek(span)) + if (span < TimeSpan.Zero || span > AudioFramework.Length) return "The point of time is not within the songlenth."; + else + AudioFramework.Position = span; return null; } [Command(AnyVisibility, "song", "Tells you the name of the current song.")] public string CommandSong(ExecutionInformation info) { - if (AudioFramework.CurrentPlayData == null) + if (PlayManager.CurrentPlayData == null) return "There is nothing on right now..."; - else if (QuizMode && AudioFramework.CurrentPlayData.Invoker.ClientId != info.TextMessage.InvokerId) + else if (QuizMode && PlayManager.CurrentPlayData.Invoker.ClientId != info.TextMessage.InvokerId) return "Sorry, you have to guess!"; else - return $"[url={FactoryManager.RestoreLink(AudioFramework.CurrentPlayData)}]{AudioFramework.CurrentPlayData.Resource.ResourceTitle}[/url]"; - } - - [Command(Private, "soundcloud", "Resolves the link as a soundcloud song to play it for you.")] - public string CommandSoundcloud(ExecutionInformation info, string parameter) - { - ClientData client = QueryConnection.GetClientById(info.TextMessage.InvokerId); - return FactoryManager.LoadAndPlay(AudioType.Soundcloud, new PlayData(info.Session, client, parameter, false)); + return $"[url={FactoryManager.RestoreLink(PlayManager.CurrentPlayData.ResourceData)}]{PlayManager.CurrentPlayData.ResourceData.ResourceTitle}[/url]"; } [Command(Private, "stop", "Stops the current song.")] @@ -903,6 +1156,12 @@ public void CommandSubscribe(ExecutionInformation info) BobController.WhisperClientSubscribe(info.TextMessage.InvokerId); } + [Command(Admin, "subscribe channel", "Adds your current channel to the music playback.")] + public void CommandSubscribeChannel(ExecutionInformation info) + { + BobController.WhisperChannelSubscribe(info.Session.Client.ChannelId, true); + } + [Command(AnyVisibility, "take", "Take a substring from a string")] [Usage(" ", "Take only parts of the text")] [Usage(" ", "Take parts, starting with the part at ")] @@ -966,30 +1225,29 @@ public string CommandTest(ExecutionInformation info) info.Session.Write("Good boy!"); // stresstest for (int i = 0; i < 10; i++) - info.Session.Write(i.ToString()); + info.Session.Write(i.ToString(CultureInfo.InvariantCulture)); return "Test end"; } } - [Command(Private, "twitch", "Resolves the link as a twitch stream to play it for you.")] - public string CommandTwitch(ExecutionInformation info, string parameter) - { - ClientData client = QueryConnection.GetClientById(info.TextMessage.InvokerId); - return FactoryManager.LoadAndPlay(AudioType.Twitch, new PlayData(info.Session, client, parameter, false)); - } - [Command(Private, "unsubscribe", "Only lets you hear the music in active channels again.")] public void CommandUnsubscribe(ExecutionInformation info) { BobController.WhisperClientUnsubscribe(info.TextMessage.InvokerId); } + [Command(Private, "unsubscribe channel", "Removes your current channel from the music playback.")] + public void CommandUnsubscribeChannel(ExecutionInformation info) + { + BobController.WhisperChannelUnsubscribe(info.Session.Client.ChannelId, true); + } + [Command(AnyVisibility, "volume", "Sets the volume level of the music.")] [Usage("", "A new volume level between 0 and 100")] public string CommandVolume(ExecutionInformation info, string parameter) { - bool relPos = parameter.StartsWith("+"); - bool relNeg = parameter.StartsWith("-"); + bool relPos = parameter.StartsWith("+", StringComparison.Ordinal); + bool relNeg = parameter.StartsWith("-", StringComparison.Ordinal); string numberString = (relPos || relNeg) ? parameter.Remove(0, 1) : parameter; int volume; @@ -1014,13 +1272,6 @@ public string CommandVolume(ExecutionInformation info, string parameter) return null; } - [Command(Private, "youtube", "Resolves the link as a youtube video to play it for you.")] - public string CommandYoutube(ExecutionInformation info, string parameter) - { - ClientData client = QueryConnection.GetClientById(info.TextMessage.InvokerId); - return FactoryManager.LoadAndPlay(AudioType.Youtube, new PlayData(info.Session, client, parameter, false)); - } - #endregion #region RESPONSES @@ -1030,7 +1281,7 @@ private bool ResponseVolume(ExecutionInformation info) Answer answer = TextUtil.GetAnswer(info.TextMessage.Message); if (answer == Answer.Yes) { - if (info.IsAdmin.Value) + if (info.IsAdmin) { var respInt = info.Session.ResponseData as int?; if (!respInt.HasValue) @@ -1053,8 +1304,8 @@ private bool ResponseQuit(ExecutionInformation info) Answer answer = TextUtil.GetAnswer(info.TextMessage.Message); if (answer == Answer.Yes) { - if (info.IsAdmin.Value) - CommandQuitForce(info); + if (info.IsAdmin) + CommandQuit(info, "force"); else info.Session.Write("Command can only be answered by an admin."); } @@ -1066,7 +1317,7 @@ private bool ResponseHistoryDelete(ExecutionInformation info) Answer answer = TextUtil.GetAnswer(info.TextMessage.Message); if (answer == Answer.Yes) { - if (info.IsAdmin.Value) + if (info.IsAdmin) { var ale = info.Session.ResponseData as AudioLogEntry; if (ale == null) @@ -1089,10 +1340,21 @@ private bool ResponseHistoryClean(ExecutionInformation info) Answer answer = TextUtil.GetAnswer(info.TextMessage.Message); if (answer == Answer.Yes) { - if (info.IsAdmin.Value) + if (info.IsAdmin) { - HistoryManager.CleanHistoryFile(); - info.Session.Write("Cleanup done!"); + string param = info.Session.ResponseData as string; + if (string.IsNullOrEmpty(param)) + { + HistoryManager.CleanHistoryFile(); + info.Session.Write("Cleanup done!"); + } + else if (param == "removedefective") + { + HistoryManager.RemoveBrokenLinks(info.Session); + info.Session.Write("Cleanup done!"); + } + else + info.Session.Write("Unknown parameter!"); } else info.Session.Write("Command can only be answered by an admin."); @@ -1100,58 +1362,91 @@ private bool ResponseHistoryClean(ExecutionInformation info) return answer != Answer.Unknown; } + private bool ResponseListDelete(ExecutionInformation info) + { + Answer answer = TextUtil.GetAnswer(info.TextMessage.Message); + if (answer == Answer.Yes) + { + var name = info.Session.ResponseData as string; + var result = PlaylistManager.DeletePlaylist(name, info.Session.ClientCached.DatabaseId, info.IsAdmin); + if (!result) info.Session.Write(result.Message); + else info.Session.Write("Ok"); + } + return answer != Answer.Unknown; + } + #endregion - public void SongUpdateEvent(object sender, PlayData data) + public void SongUpdateEvent(object sender, PlayInfoEventArgs data) { if (!QuizMode) { - QueryConnection.ChangeDescription(data.Resource.ResourceTitle); + QueryConnection.ChangeDescription(data.ResourceData.ResourceTitle); } } + public void SongStopEvent(object sender, EventArgs e) + { + QueryConnection.ChangeDescription(""); + } + + private Playlist AutoGetPlaylist(UserSession session) + { + var result = session.Get(); + if (result) + return result.Value; + + var newPlist = new Playlist(session.Client.NickName, session.ClientCached.DatabaseId); + session.Set(newPlist); + return newPlist; + } public void Dispose() { if (!isDisposed) isDisposed = true; else return; - if (PluginManager != null) + if (WebInterface != null) // before: + { + WebInterface.Dispose(); + WebInterface = null; + } + if (PluginManager != null) // before: SessionManager, logStream, { PluginManager.Dispose(); PluginManager = null; } - if (QueryConnection != null) + if (AudioFramework != null) // before: BobController, logStream, + { + AudioFramework.Dispose(); + AudioFramework = null; + } + if (BobController != null) // before: QueryConnection, logStream, + { + BobController.Dispose(); + BobController = null; + } + if (QueryConnection != null) // before: logStream, { QueryConnection.Dispose(); QueryConnection = null; } - TickPool.Close(); - if (HistoryManager != null) + TickPool.Close(); // before: + if (HistoryManager != null) // before: logStream, { HistoryManager.Dispose(); HistoryManager = null; } - if (FactoryManager != null) + if (FactoryManager != null) // before: { FactoryManager.Dispose(); FactoryManager = null; } - if (AudioFramework != null) - { - AudioFramework.Dispose(); - AudioFramework = null; - } - if (BobController != null) - { - BobController.Dispose(); - BobController = null; - } - if (SessionManager != null) + if (SessionManager != null) // before: { //sessionManager.Dispose(); SessionManager = null; } - if (logStream != null) + if (logStream != null) // before: { logStream.Dispose(); logStream = null; @@ -1159,26 +1454,6 @@ public void Dispose() } } - public class PlayData : MarshalByRefObject - { - public BotSession Session { get; private set; } - public ClientData Invoker { get; private set; } - public string Message { get; private set; } - public bool Enqueue { get; private set; } - public int Volume { get; private set; } - public AudioResource Resource { get; set; } - - public PlayData(BotSession session, ClientData invoker, string message, bool enqueue) - { - Session = session; - Invoker = invoker; - Message = message; - Enqueue = enqueue; - Resource = null; - Volume = -1; - } - } - public enum CommandRights { Admin, @@ -1193,7 +1468,7 @@ struct MainBotData [Info("path to the logfile", "log_ts3audiobot")] public string logFile; [Info("group able to execute admin commands from the bot")] - public int adminGroupId; + public ulong adminGroupId; } #pragma warning restore CS0649 } diff --git a/TS3AudioBot/PlayManager.cs b/TS3AudioBot/PlayManager.cs new file mode 100644 index 00000000..2fe00592 --- /dev/null +++ b/TS3AudioBot/PlayManager.cs @@ -0,0 +1,226 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3AudioBot +{ + using System; + using Helper; + using History; + using ResourceFactories; + using TS3Client.Messages; + + public class PlayManager + { + private MainBot botParent; + private AudioFramework audioFramework => botParent.AudioFramework; + private PlaylistManager playlistManager => botParent.PlaylistManager; + private ResourceFactoryManager resourceFactoryManager => botParent.FactoryManager; + private HistoryManager historyManager => botParent.HistoryManager; + + public PlayInfoEventArgs CurrentPlayData { get; private set; } + public bool IsPlaying => CurrentPlayData != null; + + public event EventHandler BeforeResourceStarted; + public event EventHandler AfterResourceStarted; + public event EventHandler BeforeResourceStopped; + public event EventHandler AfterResourceStopped; + + public PlayManager(MainBot parent) + { + botParent = parent; + } + + public R Enqueue(ClientData invoker, AudioResource ar) => EnqueueInternal(invoker, new PlaylistItem(ar)); + public R Enqueue(ClientData invoker, string message, AudioType? type = null) + { + var result = resourceFactoryManager.Load(message, type); + if (!result) + return result.Message; + return EnqueueInternal(invoker, new PlaylistItem(result.Value.BaseData)); + } + public R Enqueue(ClientData invoker, uint historyId) => EnqueueInternal(invoker, new PlaylistItem(historyId)); + + private R EnqueueInternal(ClientData invoker, PlaylistItem pli) + { + pli.Meta.ResourceOwnerDbId = invoker.DatabaseId; + playlistManager.AddToFreelist(pli); + + return R.OkR; + } + + /// Playes the passed + /// The invoker of this resource. Used for responses and association. + /// The resource to load and play. + /// Allows overriding certain settings for the resource. Can be null. + /// Ok if successful, or an error message otherwise. + public R Play(ClientData invoker, AudioResource ar, MetaData meta = null) + { + var result = resourceFactoryManager.Load(ar); + if (!result) + return result.Message; + return Play(invoker, result.Value, meta ?? new MetaData()); + } + /// Playes the passed + /// The invoker of this resource. Used for responses and association. + /// The associated to a factory. + /// The link to resolve, load and play. + /// Allows overriding certain settings for the resource. Can be null. + /// Ok if successful, or an error message otherwise. + public R Play(ClientData invoker, string link, AudioType? type = null, MetaData meta = null) + { + var result = resourceFactoryManager.Load(link, type); + if (!result) + return result.Message; + return Play(invoker, result.Value, meta ?? new MetaData()); + } + public R Play(ClientData invoker, uint historyId, MetaData meta = null) + { + var getresult = historyManager.GetEntryById(historyId); + if (!getresult) + return getresult.Message; + + var loadresult = resourceFactoryManager.Load(getresult.Value.AudioResource); + if (!loadresult) + return loadresult.Message; + + return Play(invoker, loadresult.Value, meta ?? new MetaData()); + } + public R Play(ClientData invoker, PlaylistItem item) + { + if (item == null) + throw new ArgumentNullException(nameof(item)); + + R lastResult = R.OkR; + ClientData realInvoker = CurrentPlayData?.Invoker ?? invoker; + + if (item.HistoryId.HasValue) + { + lastResult = Play(realInvoker, item.HistoryId.Value, item.Meta); + if (lastResult) + return R.OkR; + } + if (!string.IsNullOrWhiteSpace(item.Link)) + { + lastResult = Play(realInvoker, item.Link, item.AudioType, item.Meta); + if (lastResult) + return R.OkR; + } + if (item.Resource != null) + { + lastResult = Play(realInvoker, item.Resource, item.Meta); + if (lastResult) + return R.OkR; + } + return $"Playlist item could not be played ({lastResult.Message})"; + } + + public R Play(ClientData invoker, PlayResource play, MetaData meta) + { + if (!meta.FromPlaylist) + meta.ResourceOwnerDbId = invoker.DatabaseId; + + // add optional beforestart here. maybe for blocking/interrupting etc. + BeforeResourceStarted?.Invoke(this, new EventArgs()); + + // pass the song to the AF to start it + var result = audioFramework.StartResource(play, meta); + if (!result) return result; + + // add it to our freelist for comfort + if (!meta.FromPlaylist) + { + int index = playlistManager.InsertToFreelist(new PlaylistItem(play.BaseData, meta)); + playlistManager.Index = index; + } + + // Log our resource in the history + ulong owner = meta.ResourceOwnerDbId ?? invoker.DatabaseId; + historyManager.LogAudioResource(new HistorySaveData(play.BaseData, owner)); + + CurrentPlayData = new PlayInfoEventArgs(invoker, play, meta); // TODO meta as readonly + AfterResourceStarted?.Invoke(this, CurrentPlayData); + + return R.OkR; + } + + public R Next(ClientData invoker) + { + PlaylistItem pli = null; + for (int i = 0; i < 10; i++) + { + if ((pli = playlistManager.Next()) == null) break; + if (Play(invoker, pli)) + return R.OkR; + // optional message here that playlist entry has been skipped + } + if (pli == null) + return "No next song could be played"; + else + return "A few songs failed to start, use !next to continue"; + } + public R Previous(ClientData invoker) + { + PlaylistItem pli = null; + for (int i = 0; i < 10; i++) + { + if ((pli = playlistManager.Previous()) == null) break; + if (Play(invoker, pli)) + return R.OkR; + // optional message here that playlist entry has been skipped + } + if (pli == null) + return "No previous song could be played"; + else + return "A few songs failed to start, use !previous to continue"; + } + + public void SongStoppedHook(object sender, SongEndEventArgs e) + { + BeforeResourceStopped?.Invoke(this, e); + + if (e.SongEndedByCallback && CurrentPlayData != null && Next(CurrentPlayData.Invoker)) + return; + + CurrentPlayData = null; + AfterResourceStopped?.Invoke(this, new EventArgs()); + } + } + + public class MetaData + { + /// Defaults to: invoker.DbId - Can be set if the owner of a song differs from the invoker. + public ulong? ResourceOwnerDbId { get; set; } = null; + /// Defaults to: AudioFramwork.Defaultvolume - Overrides the starting volume. + public int? Volume { get; set; } = null; + /// Default: false - Indicates whether the song has been requested from a playlist. + public bool FromPlaylist { get; set; } = false; + } + + public class PlayInfoEventArgs : EventArgs + { + public ClientData Invoker { get; } + public PlayResource PlayResource { get; } + public AudioResource ResourceData => PlayResource.BaseData; + public MetaData MetaData { get; } + + public PlayInfoEventArgs(ClientData invoker, PlayResource playResource, MetaData meta) + { + Invoker = invoker; + PlayResource = playResource; + MetaData = meta; + } + } +} diff --git a/TS3AudioBot/PlaylistManager.cs b/TS3AudioBot/PlaylistManager.cs index 1b9c94dd..203ffc5a 100644 --- a/TS3AudioBot/PlaylistManager.cs +++ b/TS3AudioBot/PlaylistManager.cs @@ -20,16 +20,18 @@ namespace TS3AudioBot using System.Collections.Generic; using System.Linq; using System.Text.RegularExpressions; - using System.Web.Script.Serialization; - using System.Xml; - using TS3AudioBot.Algorithm; - using TS3AudioBot.Helper; - using TS3AudioBot.ResourceFactories; + using System.IO; + using System.Text; + using Algorithm; + using Helper; + using ResourceFactories; + using CommandSystem; + using System.Reflection; // TODO make public and byref when finished - public class PlaylistManager : IDisposable + public sealed class PlaylistManager : IDisposable { - private static readonly Regex ytListMatch = new Regex(@"(&|\?)list=([a-zA-Z0-9\-_]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex validPlistName = new Regex(@"^[\w-]+$", Util.DefaultRegexConfig); // get video info // https://www.googleapis.com/youtube/v3/videos?id=...,...&part=contentDetails&key=... @@ -37,215 +39,545 @@ public class PlaylistManager : IDisposable // get playlist videos // https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&maxResults=50&playlistId=...&key=... - // todo youtube playlist - // folder as playlist - // managing ? - private PlaylistManagerData data; - private JavaScriptSerializer json; + private static readonly Encoding FileEncoding = Encoding.ASCII; + private readonly Playlist freeList; + private readonly Playlist trashList; - private PlaylistMode mode; private int indexCount = 0; private IShuffleAlgorithm shuffle; - private List dataSets; - private int dataSetLength = 0; - - private Queue playQueue; + private int dataSetLength = -1; - public bool Random { get; set; } + public int Index + { + get { return Random ? shuffle.Index : indexCount; } + set + { + if (Random) + { + shuffle.Index = value; + indexCount = 0; + } + else + { + indexCount = value; + } + } + } + private bool random; + public bool Random + { + get { return random; } + set + { + random = value; + if (random) shuffle.Index = indexCount; + else indexCount = shuffle.Index; + } + } + public int Seed + { + get { return shuffle.Seed; } + set { shuffle.Seed = value; } + } + /// Loop state for the entire playlist. public bool Loop { get; set; } - internal PlaylistManager(PlaylistManagerData pmd) + // Playlistfactory related stuff + public CommandGroup CommandNode { get; } = new CommandGroup(); + private List factories; + + public PlaylistManager(PlaylistManagerData pmd) { data = pmd; - json = new JavaScriptSerializer(); - shuffle = new ListedShuffle(); - dataSets = new List(); - playQueue = new Queue(); - } + shuffle = new LinearFeedbackShiftRegister(); + shuffle.Seed = Util.RngInstance.Next(); + freeList = new Playlist(string.Empty); + trashList = new Playlist(string.Empty); - public void Enqueue(PlayData resource) - { - Random = false; - Loop = false; - mode = PlaylistMode.Queue; - playQueue.Enqueue(resource); + Util.Init(ref factories); } - public void Clear() + public PlaylistItem Current() => NPMove(0); + + public PlaylistItem Next() => NPMove(+1); + + public PlaylistItem Previous() => NPMove(-1); + + private PlaylistItem NPMove(sbyte off) { - switch (mode) + if (freeList.Count == 0) return null; + indexCount += Math.Sign(off); + + if (Loop) + indexCount = Util.MathMod(indexCount, freeList.Count); + else if (indexCount < 0 || indexCount >= freeList.Count) { - case PlaylistMode.List: - break; - case PlaylistMode.Queue: - playQueue.Clear(); - break; - default: throw new NotImplementedException(); + indexCount = Math.Max(indexCount, 0); + indexCount = Math.Min(indexCount, freeList.Count); + return null; } + + if (Random) + { + if (dataSetLength != freeList.Count) + { + dataSetLength = freeList.Count; + shuffle.Length = dataSetLength; + } + if (off > 0) shuffle.Next(); + if (off < 0) shuffle.Prev(); + } + + if (Index < 0) return null; + var entry = freeList.GetResource(Index); + if (entry == null) return null; + entry.Meta.FromPlaylist = true; + return entry; } - public PlayData Next() + public void PlayFreelist(Playlist plist) { - switch (mode) - { - case PlaylistMode.List: - indexCount++; - int pseudoListIndex; - if (Random) - pseudoListIndex = shuffle.SeedIndex(indexCount); - else - pseudoListIndex = indexCount; - return null; + if (plist == null) + throw new ArgumentNullException(nameof(plist)); - case PlaylistMode.Queue: - if (playQueue.Any()) - return playQueue.Dequeue(); - return null; + freeList.Clear(); + freeList.AddRange(plist.AsEnumerable()); + Reset(); + } - default: throw new NotImplementedException(); - } + private void Reset() + { + indexCount = 0; + dataSetLength = -1; + Index = 0; } - private void AddDataSet(DataSet set) + public int AddToFreelist(PlaylistItem item) => freeList.AddItem(item); + public int AddToTrash(PlaylistItem item) => trashList.AddItem(item); + + public int InsertToFreelist(PlaylistItem item) => freeList.InsertItem(item, Math.Min(Index + 1, freeList.Count)); + + /// Clears the current playlist + public void ClearFreelist() => freeList.Clear(); + public void ClearTrash() => trashList.Clear(); + + public R LoadPlaylist(string name, bool headOnly = false) { - if (!dataSets.Contains(set)) + if (name.StartsWith(".", StringComparison.Ordinal)) { - dataSets.Add(set); - RecalcDataSet(); + var result = GetSpecialPlaylist(name); + if (result) + return result; } - } + var fi = GetFileInfo(name); + if (!fi.Exists) + return "Playlist not found"; - private void RemoveDataSet(DataSet set) - { - if (dataSets.Contains(set)) + using (var sr = new StreamReader(fi.Open(FileMode.Open, FileAccess.Read, FileShare.Read), FileEncoding)) { - dataSets.Remove(set); - RecalcDataSet(); + Playlist plist = new Playlist(name); + + // Info: version: + // Info: owner: + // Line: :: + + string line; + + // read header + while ((line = sr.ReadLine()) != null) + { + if (string.IsNullOrEmpty(line)) + break; + + var kvp = line.Split(new[] { ':' }, 2); + if (kvp.Length < 2) continue; + + string key = kvp[0]; + string value = kvp[1]; + + switch (key) + { + case "version": // skip, not yet needed + break; + + case "owner": + if (plist.CreatorDbId != null) + return "Invalid playlist file: duplicate userid"; + ulong userid; + if (ulong.TryParse(value, out userid)) + plist.CreatorDbId = userid; + else + return "Broken playlist header"; + break; + } + } + + if (headOnly) + return plist; + + // read content + while ((line = sr.ReadLine()) != null) + { + var kvp = line.Split(new[] { ':' }, 3); + if (kvp.Length < 3) + { + Log.Write(Log.Level.Warning, "Erroneus playlist split count: {0}", line); + continue; + } + string kind = kvp[0]; + string optOwner = kvp[1]; + string content = kvp[2]; + + var meta = new MetaData(); + ulong userid; + if (string.IsNullOrWhiteSpace(optOwner)) + meta.ResourceOwnerDbId = null; + else if (ulong.TryParse(optOwner, out userid)) + meta.ResourceOwnerDbId = userid; + else + Log.Write(Log.Level.Warning, "Erroneus playlist meta data: {0}", line); + + AudioType audioType; + switch (kind) + { + case "ln": + var lnSplit = content.Split(new[] { ',' }, 2); + if (lnSplit.Length < 2) + goto default; +#pragma warning disable CS0612 + if (!string.IsNullOrWhiteSpace(lnSplit[0]) && Enum.TryParse(lnSplit[0], out audioType)) + plist.AddItem(new PlaylistItem(Uri.UnescapeDataString(lnSplit[1]), audioType, meta)); + else + plist.AddItem(new PlaylistItem(Uri.UnescapeDataString(lnSplit[1]), null, meta)); +#pragma warning restore CS0612 + break; + + case "rs": + var rsSplit = content.Split(new[] { ',' }, 3); + if (rsSplit.Length < 3) + goto default; + if (!string.IsNullOrWhiteSpace(rsSplit[0]) && Enum.TryParse(rsSplit[0], out audioType)) + plist.AddItem(new PlaylistItem(new AudioResource(Uri.UnescapeDataString(rsSplit[1]), Uri.UnescapeDataString(rsSplit[2]), audioType), meta)); + else + goto default; + break; + + case "id": + uint hid; + if (!uint.TryParse(content, out hid)) + goto default; + plist.AddItem(new PlaylistItem(hid, meta)); + break; + + default: + Log.Write(Log.Level.Warning, "Erroneus playlist data block: {0}", line); + break; + } + } + return plist; } } - private void RecalcDataSet() + public R LoadPlaylistFrom(string url, AudioType? type = null) { - dataSetLength = dataSets.Sum(s => s.Length); - shuffle.SetData(dataSetLength); + if (type.HasValue) + { + foreach (var factory in factories) + { + if (factory.FactoryFor == type.Value) + return factory.GetPlaylist(url); + } + return "There is not factory registered for this type"; + } + else + { + foreach (var factory in factories) + { + if (factory.MatchLink(url)) + return factory.GetPlaylist(url); + } + return "Unknown playlist type. Please use '!list from ' to specify your playlist type."; + } } - public void LoadYoutubePlaylist(string ytLink, bool loadLength) + public R SavePlaylist(Playlist plist) { - Match matchYtId = ytListMatch.Match(ytLink); - if (!matchYtId.Success) + if (plist == null) + throw new ArgumentNullException(nameof(plist)); + + if (!IsNameValid(plist.Name)) + return "Invalid playlist name."; + + var di = new DirectoryInfo(data.playlistPath); + if (!di.Exists) + return "No playlist directory has been set up."; + + var fi = GetFileInfo(plist.Name); + if (fi.Exists) { - // error here - return; + var tempList = LoadPlaylist(plist.Name, true); + if (!tempList) + return "Existing playlist ist corrupted, please use another name or repair the existing."; + if (tempList.Value.CreatorDbId.HasValue && tempList.Value.CreatorDbId != plist.CreatorDbId) + return "You cannot overwrite a playlist which you dont own."; } - string id = matchYtId.Groups[2].Value; - List videoList = new List(); - - bool hasNext = false; - object nextToken = null; - do + using (var sw = new StreamWriter(fi.Open(FileMode.Create, FileAccess.Write, FileShare.Read), FileEncoding)) { - var queryString = new Uri($"https://www.googleapis.com/youtube/v3/playlistItems?part=contentDetails&maxResults=50&playlistId={id}{(hasNext ? ("&pageToken=" + nextToken) : string.Empty)}&key={data.youtubeApiKey}"); + sw.WriteLine("version:1"); - string response; - if (!WebWrapper.DownloadString(out response, queryString)) - throw new Exception(); // TODO correct error handling - var parsed = (Dictionary)json.DeserializeObject(response); - var videoDicts = ((object[])parsed["items"]).Cast>().ToArray(); - YoutubePlaylistItem[] itemBuffer = new YoutubePlaylistItem[videoDicts.Length]; - for (int i = 0; i < videoDicts.Length; i++) - itemBuffer[i] = new YoutubePlaylistItem - { - AudioType = AudioType.Youtube, - Id = (string)(((Dictionary)videoDicts[i]["contentDetails"])["videoId"]), - }; - hasNext = parsed.TryGetValue("nextPageToken", out nextToken); + if (plist.CreatorDbId.HasValue) + { + sw.Write("owner:"); + sw.Write(plist.CreatorDbId.Value); + sw.WriteLine(); + } - if (loadLength) + sw.WriteLine(); + + foreach (var pli in plist.AsEnumerable()) { - queryString = new Uri($"https://www.googleapis.com/youtube/v3/videos?id={string.Join(",", itemBuffer.Select(item => item.Id))}&part=contentDetails&key={data.youtubeApiKey}"); - if (!WebWrapper.DownloadString(out response, queryString)) - throw new Exception(); // TODO correct error handling - parsed = (Dictionary)json.DeserializeObject(response); - videoDicts = ((object[])parsed["items"]).Cast>().ToArray(); - for (int i = 0; i < videoDicts.Length; i++) - itemBuffer[i].Length = XmlConvert.ToTimeSpan((string)(((Dictionary)videoDicts[i]["contentDetails"])["duration"])); + if (pli.HistoryId.HasValue) + { + sw.Write("id:"); + if (pli.Meta.ResourceOwnerDbId.HasValue) + sw.Write(pli.Meta.ResourceOwnerDbId.Value); + sw.Write(":"); + sw.Write(pli.HistoryId.Value); + } + else if (!string.IsNullOrWhiteSpace(pli.Link)) + { + sw.Write("ln:"); + if (pli.Meta.ResourceOwnerDbId.HasValue) + sw.Write(pli.Meta.ResourceOwnerDbId.Value); + sw.Write(":"); + if (pli.AudioType.HasValue) + sw.Write(pli.AudioType.Value); + sw.Write(","); + sw.Write(Uri.EscapeDataString(pli.Link)); + } + else if (pli.Resource != null) + { + sw.Write("rs:"); + if (pli.Meta.ResourceOwnerDbId.HasValue) + sw.Write(pli.Meta.ResourceOwnerDbId.Value); + sw.Write(":"); + sw.Write(pli.Resource.AudioType); + sw.Write(","); + sw.Write(Uri.EscapeDataString(pli.Resource.ResourceId)); + sw.Write(","); + sw.Write(Uri.EscapeDataString(pli.Resource.ResourceTitle)); + } + else + continue; + + sw.WriteLine(); } + } - videoList.AddRange(itemBuffer); - } while (hasNext); + return R.OkR; } - public void Dispose() { } - } + private FileInfo GetFileInfo(string name) => new FileInfo(Path.Combine(data.playlistPath, name ?? string.Empty)); - enum PlaylistMode - { - List, - Queue, - } + public R DeletePlaylist(string name, ulong requestingClientDbId, bool force = false) + { + var fi = GetFileInfo(name); + if (!fi.Exists) + return "Playlist not found"; + else if (!force) + { + var tempList = LoadPlaylist(name, true); + if (!tempList) + return "Existing playlist ist corrupted, please use another name or repair the existing."; + if (tempList.Value.CreatorDbId.HasValue && tempList.Value.CreatorDbId != requestingClientDbId) + return "You cannot delete a playlist which you dont own."; + } - abstract class DataSet - { - public int Length { get; protected set; } - public bool NeedRecalc { get; set; } + try + { + fi.Delete(); + return R.OkR; + } + catch (IOException) { return "File still in use"; } + catch (System.Security.SecurityException) { return "Missing rights to delete this file"; } + } - public abstract AudioResource GetResource(int index); - } + public static R IsNameValid(string name) + { + if (name.Length >= 64) + return "Length must be <64"; + if (!validPlistName.IsMatch(name)) + return "The new name is invalid please only use [a-zA-Z0-9_-]"; + return R.OkR; + } - class FreeSet : DataSet - { - private HashSet resourceSet; - private List resources; + public IEnumerable GetAvailablePlaylists() => GetAvailablePlaylists(null); + public IEnumerable GetAvailablePlaylists(string pattern) + { + var di = new DirectoryInfo(data.playlistPath); + if (!di.Exists) + return Enumerable.Empty(); + + IEnumerable fileEnu; + if (string.IsNullOrEmpty(pattern)) + fileEnu = di.EnumerateFiles(); + else + fileEnu = di.EnumerateFiles(pattern, SearchOption.TopDirectoryOnly); + + return fileEnu.Select(fi => fi.Name); + } - public FreeSet() + private R GetSpecialPlaylist(string name) { - resourceSet = new HashSet(); - resources = new List(); + if (!name.StartsWith(".", StringComparison.Ordinal)) + return "Not a reserved list type."; + + switch (name) + { + case ".queue": return freeList; + case ".trash": return trashList; + default: return "Special list not found"; + } } - public void AddResource(AudioResource resource) + public void AddFactory(IPlaylistFactory factory) + { + factories.Add(factory); + + // register factory command node + var playCommand = new PlayCommand(factory.FactoryFor); + CommandNode.AddCommand(factory.SubCommandName, playCommand.Command); + } + + public void Dispose() { } + + sealed class PlayCommand { - if (!resourceSet.Contains(resource)) + public BotCommand Command { get; } + private AudioType audioType; + private static readonly MethodInfo playMethod = typeof(PlayCommand).GetMethod(nameof(PropagiateLoad)); + + public PlayCommand(AudioType audioType) + { + this.audioType = audioType; + var builder = new CommandBuildInfo( + this, + playMethod, + new CommandAttribute(CommandRights.Private, string.Empty), + null); + Command = new BotCommand(builder); + } + + public string PropagiateLoad(ExecutionInformation info, string parameter) { + var result = info.Session.Bot.PlaylistManager.LoadPlaylistFrom(parameter, audioType); - Length++; + if (!result) + return result; + + result.Value.CreatorDbId = info.Session.ClientCached.DatabaseId; + info.Session.Set(result.Value); + return "Ok"; } } + } - public void RemoveResource(AudioResource resource) + public class PlaylistItem + { + public MetaData Meta { get; } + //one of these: + // playdata holds all needed information for playing + first possiblity + // > can be a resource + public AudioResource Resource { get; } = null; + // > can be a history entry (will need to fall back to resource-load if entry is deleted in meanwhile) + public uint? HistoryId { get; } = null; + // > can be a link to be resolved normally (+ optional audio type) + public string Link { get; } = null; + public AudioType? AudioType { get; } = null; + + public string DisplayString { - if (!resourceSet.Contains(resource)) + get { - + if (Resource != null) + return Resource.ResourceTitle ?? $"{Resource.AudioType}: {Resource.ResourceId}"; + else if (HistoryId.HasValue) + return $"HistoryID: {HistoryId.Value}"; + else if (!string.IsNullOrWhiteSpace(Link)) + return (AudioType.HasValue ? AudioType.Value + ": " : string.Empty) + Link; + else + return ""; } } - public override AudioResource GetResource(int index) => resources[index]; + private PlaylistItem(MetaData meta) { Meta = meta ?? new MetaData(); } + public PlaylistItem(AudioResource resource, MetaData meta = null) : this(meta) { Resource = resource; } + public PlaylistItem(uint hId, MetaData meta = null) : this(meta) { HistoryId = hId; } + [Obsolete] + public PlaylistItem(string message, AudioType? type, MetaData meta = null) : this(meta) { Link = message; AudioType = type; } } - class YoutubePlaylist : DataSet + public class Playlist { - public override AudioResource GetResource(int index) + // metainfo + public string Name { get; set; } + public ulong? CreatorDbId { get; set; } + // file behaviour: persistent playlist will be synced to a file + public bool FilePersistent { get; set; } + // playlist data + public int Count => resources.Count; + private List resources; + + public Playlist(string name) : this(name, null) { } + public Playlist(string name, ulong? creatorDbId) + { + Util.Init(ref resources); + CreatorDbId = creatorDbId; + Name = name; + } + + public int AddItem(PlaylistItem item) + { + resources.Add(item); + return resources.Count - 1; + } + + public int InsertItem(PlaylistItem item, int index) + { + resources.Insert(index, item); + return index; + } + + public void AddRange(IEnumerable items) => resources.AddRange(items); + + public void RemoveItemAt(int i) { - throw new NotImplementedException(); + if (i < 0 || i >= resources.Count) + return; + resources.RemoveAt(i); } + + public void Clear() => resources.Clear(); + + public IEnumerable AsEnumerable() => resources; + + public PlaylistItem GetResource(int index) => resources[index]; } - class YoutubePlaylistItem + class YoutubePlaylistItem : PlaylistItem { - public AudioType AudioType { get; set; } - public string Id { get; set; } public TimeSpan Length { get; set; } + + public YoutubePlaylistItem(AudioResource resource) : base(resource) { } } #pragma warning disable CS0649 - struct PlaylistManagerData + public struct PlaylistManagerData { - [Info("a youtube apiv3 'Browser' type key")] - public string youtubeApiKey; + [Info("absolute or relative path the playlist folder", "Playlists")] + public string playlistPath; } #pragma warning restore CS0649 } diff --git a/TS3AudioBot/PluginManager.cs b/TS3AudioBot/PluginManager.cs index 43d84d8e..2ffae675 100644 --- a/TS3AudioBot/PluginManager.cs +++ b/TS3AudioBot/PluginManager.cs @@ -19,6 +19,7 @@ namespace TS3AudioBot using System; using System.CodeDom.Compiler; using System.Collections.Generic; + using System.Globalization; using System.IO; using System.Linq; using System.Linq.Expressions; @@ -98,7 +99,7 @@ private void ClearMissingFiles() } } - public string LoadPlugin(string identifier) + public R LoadPlugin(string identifier) { CheckLocalPlugins(); @@ -118,7 +119,7 @@ public string LoadPlugin(string identifier) return LoadPlugin(plugin); } - private string LoadPlugin(Plugin plugin) + private R LoadPlugin(Plugin plugin) { if (plugin == null) return "Plugin not found"; @@ -137,7 +138,7 @@ private string LoadPlugin(Plugin plugin) plugin.proxy.Run(mainBot); mainBot.CommandManager.RegisterPlugin(plugin); plugin.status = PluginStatus.Active; - return "Ok"; + return R.OkR; } catch (Exception ex) { @@ -204,13 +205,14 @@ public string GetPluginOverview() int digits = (int)Math.Floor(Math.Log10(plugins.Count) + 1); foreach (var plugin in plugins.Values) { - strb.Append("#").Append(plugin.Id.ToString("D" + digits)).Append('|'); + strb.Append("#").Append(plugin.Id.ToString("D" + digits, CultureInfo.InvariantCulture)).Append('|'); switch (plugin.status) { case PluginStatus.Off: strb.Append("OFF"); break; case PluginStatus.Ready: strb.Append("RDY"); break; case PluginStatus.Active: strb.Append("+ON"); break; case PluginStatus.Disabled: strb.Append("UNL"); break; + case PluginStatus.Error: strb.Append("ERR"); break; default: throw new InvalidProgramException(); } strb.Append('|').AppendLine(plugin.proxy?.Name ?? ""); @@ -231,7 +233,7 @@ public interface ITS3ABPlugin : IDisposable void Initialize(MainBot bot); } - public class Plugin : MarshalByRefObject + public class Plugin { private MainBot mainBot; public int Id { get; } @@ -361,7 +363,7 @@ public void Unload() } } - internal class PluginProxy : MarshalByRefObject + internal class PluginProxy { private Type pluginType; private Assembly assembly; @@ -393,7 +395,7 @@ private PluginResponse LoadAssembly(AppDomain domain, Assembly assembly) var types = assembly.GetExportedTypes().Where(t => typeof(ITS3ABPlugin).IsAssignableFrom(t)); var pluginOk = PluginCountCheck(types); if (pluginOk != PluginResponse.Ok) return pluginOk; - + pluginType = types.First(); return PluginResponse.Ok; } @@ -496,6 +498,8 @@ public enum PluginStatus Active, /// The plugin has been plugged off intentionally and will not be prepared with the next scan. Disabled, + /// The plugin failed to load. + Error, } public enum PluginResponse diff --git a/TS3AudioBot/QueryConnection.cs b/TS3AudioBot/QueryConnection.cs index 70b7ef7d..efb6726f 100644 --- a/TS3AudioBot/QueryConnection.cs +++ b/TS3AudioBot/QueryConnection.cs @@ -19,32 +19,45 @@ namespace TS3AudioBot using System; using System.Collections.Generic; using System.Linq; - using TS3AudioBot.Helper; - using TS3Query; - using TS3Query.Messages; + using Helper; + using TS3Client; + using TS3Client.Query; + using TS3Client.Messages; - public class QueryConnection : MarshalByRefObject, IDisposable + public sealed class QueryConnection : ITeamspeakControl { public event EventHandler OnMessageReceived; - private void ExtendedTextMessage(object sender, TextMessage eventArgs) + private void ExtendedTextMessage(object sender, IEnumerable eventArgs) { - if (connectionData.suppressLoopback && eventArgs.InvokerId == me.ClientId) - return; - OnMessageReceived?.Invoke(sender, eventArgs); + if (OnMessageReceived == null) return; + foreach (var evData in eventArgs) + { + if (connectionData.suppressLoopback && evData.InvokerId == me.ClientId) + continue; + OnMessageReceived?.Invoke(sender, evData); + } } public event EventHandler OnClientConnect; - private void ExtendedClientEnterView(object sender, ClientEnterView eventArgs) + private void ExtendedClientEnterView(object sender, IEnumerable eventArgs) { - clientbufferOutdated = true; - OnClientConnect?.Invoke(sender, eventArgs); + if (OnClientConnect == null) return; + foreach (var evData in eventArgs) + { + clientbufferOutdated = true; + OnClientConnect?.Invoke(sender, evData); + } } public event EventHandler OnClientDisconnect; - private void ExtendedClientLeftView(object sender, ClientLeftView eventArgs) + private void ExtendedClientLeftView(object sender, IEnumerable eventArgs) { - clientbufferOutdated = true; - OnClientDisconnect?.Invoke(sender, eventArgs); + if (OnClientDisconnect == null) return; + foreach (var evData in eventArgs) + { + clientbufferOutdated = true; + OnClientDisconnect?.Invoke(sender, evData); + } } private IEnumerable clientbuffer; @@ -54,7 +67,7 @@ private void ExtendedClientLeftView(object sender, ClientLeftView eventArgs) private QueryConnectionData connectionData; private static readonly TimeSpan PingInterval = TimeSpan.FromSeconds(60); - internal TS3QueryClient tsClient { get; private set; } + private TS3QueryClient tsClient; private ClientData me; public QueryConnection(QueryConnectionData qcd) @@ -66,35 +79,41 @@ public QueryConnection(QueryConnectionData qcd) tsClient.OnClientLeftView += ExtendedClientLeftView; tsClient.OnClientEnterView += ExtendedClientEnterView; tsClient.OnTextMessageReceived += ExtendedTextMessage; + tsClient.OnConnected += OnConnected; } public void Connect() { if (!tsClient.IsConnected) { - tsClient.Connect(connectionData.host, connectionData.port); + tsClient.Connect(new ConnectionData() { Hostname = connectionData.host, Port = connectionData.port }); tsClient.Login(connectionData.user, connectionData.passwd); tsClient.UseServer(1); try { tsClient.ChangeName("TS3AudioBot"); } - catch (QueryCommandException) { Log.Write(Log.Level.Warning, "TS3AudioBot name already in use!"); } + catch (TS3CommandException) { Log.Write(Log.Level.Warning, "TS3AudioBot name already in use!"); } + } + } - me = GetSelf(); + private void OnConnected(object sender, EventArgs e) + { + me = GetSelf(); - tsClient.RegisterNotification(MessageTarget.Server, -1); - tsClient.RegisterNotification(MessageTarget.Private, -1); - tsClient.RegisterNotification(RequestTarget.Server, -1); + tsClient.RegisterNotification(MessageTarget.Server, -1); + tsClient.RegisterNotification(MessageTarget.Private, -1); + tsClient.RegisterNotification(RequestTarget.Server, -1); - TickPool.RegisterTick(() => tsClient.WhoAmI(), PingInterval, true); - } + TickPool.RegisterTick(() => tsClient.WhoAmI(), PingInterval, true); } + public void EnterEventLoop() => tsClient.EnterEventLoop(); + private void Diconnect() { if (tsClient.IsConnected) - tsClient.Close(); + tsClient.Disconnect(); } - public void SendMessage(string message, ClientData client) => tsClient.SendMessage(message, client); + public void SendMessage(string message, ushort clientId) => tsClient.SendMessage(MessageTarget.Private, clientId, message); public void SendGlobalMessage(string message) => tsClient.SendMessage(MessageTarget.Server, 1, message); public void KickClientFromServer(ushort clientId) => tsClient.KickClientFromServer(new[] { clientId }); public void KickClientFromChannel(ushort clientId) => tsClient.KickClientFromChannel(new[] { clientId }); @@ -105,7 +124,7 @@ public ClientData GetClientById(ushort id) var cd = ClientBufferRequest(client => client.ClientId == id); if (cd != null) return cd; Log.Write(Log.Level.Warning, "Slow double request, due to missing or wrong permission confinguration!"); - cd = tsClient.Send("clientinfo", new Parameter("clid", id)).FirstOrDefault(); + cd = tsClient.Send("clientinfo", new CommandParameter("clid", id)).FirstOrDefault(); if (cd != null) { cd.ClientId = id; @@ -136,7 +155,7 @@ public ClientData GetSelf() cd.DatabaseId = data.DatabaseId; cd.ClientId = data.ClientId; cd.NickName = data.NickName; - cd.ClientType = ClientType.Query; + cd.ClientType = tsClient.ClientType; return cd; } @@ -149,7 +168,7 @@ public void RefreshClientBuffer(bool force) } } - public int[] GetClientServerGroups(ClientData client) + public ulong[] GetClientServerGroups(ClientData client) { if (client == null) throw new ArgumentNullException(nameof(client)); @@ -157,7 +176,7 @@ public int[] GetClientServerGroups(ClientData client) Log.Write(Log.Level.Debug, "QC GetClientServerGroups called"); var response = tsClient.ServerGroupsOfClientDbId(client); if (!response.Any()) - return new int[0]; + return new ulong[0]; return response.Select(csg => csg.ServerGroupId).ToArray(); } @@ -174,7 +193,7 @@ public string GetNameByDbId(ulong clientDbId) clientDbNames.Add(clientDbId, name); return name; } - catch (QueryCommandException) { return null; } + catch (TS3CommandException) { return null; } } public void Dispose() diff --git a/TS3AudioBot/ResourceFactories/AudioResource.cs b/TS3AudioBot/ResourceFactories/AudioResource.cs index 45666bb3..79f3c6eb 100644 --- a/TS3AudioBot/ResourceFactories/AudioResource.cs +++ b/TS3AudioBot/ResourceFactories/AudioResource.cs @@ -18,30 +18,49 @@ namespace TS3AudioBot.ResourceFactories { using System; - public abstract class AudioResource : MarshalByRefObject + public sealed class PlayResource + { + public AudioResource BaseData { get; } + public string PlayUri { get; } + + public PlayResource(string uri, AudioResource baseData) + { + BaseData = baseData; + PlayUri = uri; + } + + public override string ToString() => BaseData.ToString(); + } + + public class AudioResource { /// The resource type. - public abstract AudioType AudioType { get; } - /// The display title. - public string ResourceTitle { get; set; } - /// An identifier to create the song. This id is uniqe among same resources. + public AudioType AudioType { get; } + /// An identifier to create the song. This id is uniqe among same resources. public string ResourceId { get; } - /// An identifier wich is unique among all and . + /// The display title. + public string ResourceTitle { get; } + /// An identifier wich is unique among all and . public string UniqueId => ResourceId + AudioType.ToString(); - - protected AudioResource(string resourceId, string resourceTitle) + + public AudioResource(string resourceId, string resourceTitle, AudioType type) { - ResourceTitle = resourceTitle; ResourceId = resourceId; + ResourceTitle = resourceTitle; + AudioType = type; } - public abstract string Play(); - - public override string ToString() + public AudioResource(AudioResource copyResource) { - return $"{AudioType}: {ResourceTitle} (ID:{ResourceId})"; + ResourceId = copyResource.ResourceId; + ResourceTitle = copyResource.ResourceTitle; + AudioType = copyResource.AudioType; } + public AudioResource WithName(string newName) => new AudioResource(ResourceId, newName, AudioType); + + public AudioResource Clone() => new AudioResource(ResourceId, ResourceTitle, AudioType); + public override bool Equals(object obj) { if (obj == null) @@ -61,5 +80,10 @@ public override int GetHashCode() hash = (hash * 0x1FFFF) + ResourceId.GetHashCode(); return hash; } + + public override string ToString() + { + return $"{AudioType} ID:{ResourceId}"; + } } } diff --git a/TS3AudioBot/ResourceFactories/IPlaylistFactory.cs b/TS3AudioBot/ResourceFactories/IPlaylistFactory.cs new file mode 100644 index 00000000..62e272ae --- /dev/null +++ b/TS3AudioBot/ResourceFactories/IPlaylistFactory.cs @@ -0,0 +1,14 @@ +namespace TS3AudioBot.ResourceFactories +{ + using Helper; + + public interface IPlaylistFactory + { + string SubCommandName { get; } + AudioType FactoryFor { get; } + + bool MatchLink(string uri); + + R GetPlaylist(string url); + } +} \ No newline at end of file diff --git a/TS3AudioBot/ResourceFactories/IResourceFactory.cs b/TS3AudioBot/ResourceFactories/IResourceFactory.cs index e347b26a..9c596143 100644 --- a/TS3AudioBot/ResourceFactories/IResourceFactory.cs +++ b/TS3AudioBot/ResourceFactories/IResourceFactory.cs @@ -17,15 +17,29 @@ namespace TS3AudioBot.ResourceFactories { using System; + using Helper; public interface IResourceFactory : IDisposable { + string SubCommandName { get; } AudioType FactoryFor { get; } + /// Check method to ask if a factory can load the given link. + /// Any link or something similar a user can obtain to pass it here. + /// True if the factory thinks it can parse it, false otherwise. bool MatchLink(string uri); - RResultCode GetResource(string url, out AudioResource resource); - RResultCode GetResourceById(string id, string name, out AudioResource resource); + /// The factory will try to parse the uri and create a playable resource from it. + /// Any link or something similar a user can obtain to pass it here. + /// The playable resource if successful, or an error message otherwise + R GetResource(string url); + /// The factory will try to parse the unique identifier of its scope of responsibility and create a playable resource from it. + /// The unique id for a song this factory is responsible for. + /// A custom dislay name for the song. Can be null to tell the factory to restore the original one. + /// The playable resource if successful, or an error message otherwise + R GetResourceById(AudioResource resource); + /// Gets a link to the original site/location. This may differ from the link the resource was orininally created. + /// The unique id for a song this factory is responsible for. + /// The (close to) original link if successful, null otherwise. string RestoreLink(string id); - void PostProcess(PlayData data, out bool abortPlay); } } diff --git a/TS3AudioBot/ResourceFactories/MediaFactory.cs b/TS3AudioBot/ResourceFactories/MediaFactory.cs index 18952b66..9538be53 100644 --- a/TS3AudioBot/ResourceFactories/MediaFactory.cs +++ b/TS3AudioBot/ResourceFactories/MediaFactory.cs @@ -18,162 +18,152 @@ namespace TS3AudioBot.ResourceFactories { using System; using System.IO; + using System.Linq; using Helper; using Helper.AudioTags; - using CommandSystem; - public sealed class MediaFactory : IResourceFactory + public sealed class MediaFactory : IResourceFactory, IPlaylistFactory { + string IResourceFactory.SubCommandName => "link"; + string IPlaylistFactory.SubCommandName => "folder"; public AudioType FactoryFor => AudioType.MediaLink; - public bool MatchLink(string uri) => true; + bool IResourceFactory.MatchLink(string uri) => true; + bool IPlaylistFactory.MatchLink(string uri) => Directory.Exists(uri); - public RResultCode GetResource(string uri, out AudioResource resource) + public R GetResource(string uri) { - return GetResourceById(uri, null, out resource); + return GetResourceById(new AudioResource(uri, null, AudioType.MediaLink)); } - public RResultCode GetResourceById(string id, string name, out AudioResource resource) + public R GetResourceById(AudioResource resource) { - var result = ValidateUri(id, ref name, id); + var result = ValidateUri(resource.ResourceId); - if (result == RResultCode.MediaNoWebResponse) + if (!result) { - resource = null; - return result; + return result.Message; } else { - resource = new MediaResource(id, name, id, result); - return RResultCode.Success; + var resData = result.Value; + AudioResource finalResource; + if (resource.ResourceTitle != null) + finalResource = resource; + else if (!string.IsNullOrWhiteSpace(resData.Title)) + finalResource = resource.WithName(resData.Title); + else + finalResource = resource.WithName(resource.ResourceId); + return new PlayResource(resData.FullUri, finalResource); } } public string RestoreLink(string id) => id; - private static RResultCode ValidateUri(string id, ref string name, string uri) + private static R ValidateUri(string uri) { Uri uriResult; if (!Uri.TryCreate(uri, UriKind.RelativeOrAbsolute, out uriResult)) - return RResultCode.MediaInvalidUri; + return R.Err(RResultCode.MediaInvalidUri.ToString()); - try - { - string scheme = uriResult.Scheme; - if (scheme == Uri.UriSchemeHttp - || scheme == Uri.UriSchemeHttps - || scheme == Uri.UriSchemeFtp) - return ValidateWeb(id, ref name, uri); - else if (uriResult.Scheme == Uri.UriSchemeFile) - return ValidateFile(id, ref name, uri); - else - return RResultCode.MediaUnknownUri; - } - catch (InvalidOperationException) + string fullUri = uri; + if (!uriResult.IsAbsoluteUri) { - return ValidateFile(id, ref name, uri); - } - } + try { fullUri = Path.GetFullPath(uri); } + catch (Exception ex) when (ex is ArgumentException || ex is NotSupportedException || ex is PathTooLongException || ex is System.Security.SecurityException) { } - private static void GetStreamData(string id, ref string name, Stream stream) - { - if (string.IsNullOrEmpty(name)) - { - if (stream != null) - { - name = AudioTagReader.GetTitle(stream); - name = string.IsNullOrWhiteSpace(name) ? id : name; - } - else name = id; + if (!Uri.TryCreate(fullUri, UriKind.Absolute, out uriResult)) + return R.Err(RResultCode.MediaInvalidUri.ToString()); } + + if (uriResult.Scheme == Uri.UriSchemeHttp + || uriResult.Scheme == Uri.UriSchemeHttps + || uriResult.Scheme == Uri.UriSchemeFtp) + return ValidateWeb(uriResult); + else if (uriResult.Scheme == Uri.UriSchemeFile) + return ValidateFile(fullUri); + else + return R.Err(RResultCode.MediaUnknownUri.ToString()); } - private static RResultCode ValidateWeb(string id, ref string name, string link) + private static string GetStreamName(Stream stream) + => AudioTagReader.GetTitle(stream) ?? string.Empty; + + private static R ValidateWeb(Uri link) { - string refname = name; - if (WebWrapper.GetResponse(new Uri(link), response => { using (var stream = response.GetResponseStream()) GetStreamData(id, ref refname, stream); }) != ValidateCode.Ok) - return RResultCode.MediaNoWebResponse; + string outName = null; + var valCode = WebWrapper.GetResponse(link, response => + { + using (var stream = response.GetResponseStream()) + outName = GetStreamName(stream); + }); - name = refname; - return RResultCode.Success; + if (valCode == ValidateCode.Ok) + { + return R.OkR(new ResData(link.AbsoluteUri, outName)); + } + else + { + return R.Err(RResultCode.MediaNoWebResponse.ToString()); + } } - private static RResultCode ValidateFile(string id, ref string name, string path) + private static R ValidateFile(string path) { if (!File.Exists(path)) - return RResultCode.MediaFileNotFound; + return R.Err(RResultCode.MediaFileNotFound.ToString()); try { using (var stream = File.Open(path, FileMode.Open, FileAccess.Read)) { - GetStreamData(id, ref name, stream); - return RResultCode.Success; + return R.OkR(new ResData(path, GetStreamName(stream))); } } - catch (PathTooLongException) { return RResultCode.AccessDenied; } - catch (DirectoryNotFoundException) { return RResultCode.MediaFileNotFound; } - catch (FileNotFoundException) { return RResultCode.MediaFileNotFound; } - catch (IOException) { return RResultCode.AccessDenied; } - catch (UnauthorizedAccessException) { return RResultCode.AccessDenied; } - catch (NotSupportedException) { return RResultCode.AccessDenied; } + // TODO: correct errors + catch (PathTooLongException) { return R.Err(RResultCode.AccessDenied.ToString()); } + catch (DirectoryNotFoundException) { return R.Err(RResultCode.MediaFileNotFound.ToString()); } + catch (FileNotFoundException) { return R.Err(RResultCode.MediaFileNotFound.ToString()); } + catch (IOException) { return R.Err(RResultCode.AccessDenied.ToString()); } + catch (UnauthorizedAccessException) { return R.Err(RResultCode.AccessDenied.ToString()); } + catch (NotSupportedException) { return R.Err(RResultCode.AccessDenied.ToString()); } } - public void PostProcess(PlayData data, out bool abortPlay) - { - MediaResource mediaResource = (MediaResource)data.Resource; - if (mediaResource.InternalResultCode == RResultCode.Success) - { - abortPlay = false; - } - else - { - abortPlay = true; - data.Session.Write( - $"This uri might be invalid ({mediaResource.InternalResultCode}), do you want to start anyway?"); - data.Session.UserResource = data; - data.Session.SetResponse(ResponseValidation, null); - } - } + public void Dispose() { } - private static bool ResponseValidation(ExecutionInformation info) + public R GetPlaylist(string url) { - Answer answer = TextUtil.GetAnswer(info.TextMessage.Message); - if (answer == Answer.Yes) - { - PlayData data = info.Session.UserResource; - info.Session.Bot.FactoryManager.Play(data); - } - else if (answer == Answer.No) + if (!Directory.Exists(url)) + return R.Err(RResultCode.MediaFileNotFound.ToString()); + + try { - info.Session.UserResource = null; + var di = new DirectoryInfo(url); + var plist = new Playlist(di.Name); + var resources = from file in di.EnumerateFiles() + select ValidateFile(file.FullName) into result + where result.Ok + select result.Value into val + select new AudioResource(val.FullUri, string.IsNullOrWhiteSpace(val.Title) ? val.FullUri : val.Title, AudioType.MediaLink) into res + select new PlaylistItem(res); + plist.AddRange(resources); + + return plist; } - return answer != Answer.Unknown; - } - - public void Dispose() - { - + // TODO: correct errors + catch (PathTooLongException) { return R.Err(RResultCode.AccessDenied.ToString()); } + catch (ArgumentException) { return R.Err(RResultCode.MediaFileNotFound.ToString()); } } } - public sealed class MediaResource : AudioResource + class ResData { - public override AudioType AudioType => AudioType.MediaLink; - - public string ResourceURL { get; private set; } - public RResultCode InternalResultCode { get; private set; } - - public MediaResource(string id, string name, string url, RResultCode internalRC) - : base(id, name) - { - ResourceURL = url; - InternalResultCode = internalRC; - } - - public override string Play() + public string FullUri { get; set; } + public string Title { get; set; } + public ResData(string fullUri, string title) { - return ResourceURL; + FullUri = fullUri; + Title = title; } } } diff --git a/TS3AudioBot/ResourceFactories/ResourceFactoryManager.cs b/TS3AudioBot/ResourceFactories/ResourceFactoryManager.cs index 5f2d6c04..4297efb1 100644 --- a/TS3AudioBot/ResourceFactories/ResourceFactoryManager.cs +++ b/TS3AudioBot/ResourceFactories/ResourceFactoryManager.cs @@ -18,105 +18,97 @@ namespace TS3AudioBot.ResourceFactories { using System; using System.Collections.Generic; + using System.Reflection; + using System.Reflection.Emit; + using CommandSystem; using Helper; - using History; + using TS3Client.Messages; - public sealed class ResourceFactoryManager : MarshalByRefObject, IDisposable + public sealed class ResourceFactoryManager : IDisposable { + public CommandGroup CommandNode { get; } = new CommandGroup(); public IResourceFactory DefaultFactorty { get; internal set; } - private IList factories; - private AudioFramework audioFramework; + private List factories; - public ResourceFactoryManager(AudioFramework audioFramework) + public ResourceFactoryManager() { - factories = new List(); - this.audioFramework = audioFramework; + Util.Init(ref factories); } - public string LoadAndPlay(PlayData data) - { - string netlinkurl = TextUtil.ExtractUrlFromBB(data.Message); - IResourceFactory factory = GetFactoryFor(netlinkurl); - return LoadAndPlay(factory, data); - } + // Load lookup stages + // PlayResource != null => ret PlayResource + // ResourceData != null => call RF.RestoreFromId + // TextMessage != null => call RF.GetResoruce + // else => ret Error - public string LoadAndPlay(AudioType audioType, PlayData data) + /// + /// Creates a new which can be played. + /// The build data will be taken from or + /// if no AudioResource is given. + /// + /// The building parameters for the resource. + /// The playable resource if successful, or an error message otherwise. + public R Load(AudioResource resource) { - var factory = GetFactoryFor(audioType); - return LoadAndPlay(factory, data); - } + if (resource == null) + throw new ArgumentNullException(nameof(resource)); - private string LoadAndPlay(IResourceFactory factory, PlayData data) - { - if (data.Resource == null) - { - string netlinkurl = TextUtil.ExtractUrlFromBB(data.Message); + IResourceFactory factory = GetFactoryFor(resource.AudioType); - AudioResource resource; - RResultCode result = factory.GetResource(netlinkurl, out resource); - if (result != RResultCode.Success) - return $"Could not play ({result})"; - data.Resource = resource; - } - return PostProcessStart(factory, data); + var result = factory.GetResourceById(resource); + if (!result) + return $"Could not load ({result.Message})"; + return result; } - public string RestoreAndPlay(AudioLogEntry logEntry, PlayData data) + /// + /// Same as except it lets you pick an + /// identifier to manually select a factory. + /// + /// The link/uri to resolve for the resource. + /// The associated to a factory. + /// The playable resource if successful, or an error message otherwise. + public R Load(string message, AudioType? audioType = null) { - var factory = GetFactoryFor(logEntry.AudioType); - - if (data.Resource == null) - { - AudioResource resource; - RResultCode result = factory.GetResourceById(logEntry.ResourceId, logEntry.ResourceTitle, out resource); - if (result != RResultCode.Success) - return $"Could not restore ({result})"; - data.Resource = resource; - } - return PostProcessStart(factory, data); - } + if (string.IsNullOrWhiteSpace(message)) + throw new ArgumentNullException(nameof(message)); - private string PostProcessStart(IResourceFactory factory, PlayData data) - { - bool abortPlay; - factory.PostProcess(data, out abortPlay); - return abortPlay ? null : Play(data); - } + IResourceFactory factory; + string netlinkurl = TextUtil.ExtractUrlFromBB(message); - public string Play(PlayData data) - { - if (data.Enqueue && audioFramework.IsPlaying) - { - audioFramework.PlaylistManager.Enqueue(data); - } + if (audioType.HasValue) + factory = GetFactoryFor(audioType.Value); else - { - var result = audioFramework.StartResource(data); - if (result != AudioResultCode.Success) - return $"The resource could not be played ({result})."; - } - return null; + factory = GetFactoryFor(netlinkurl); + + var result = factory.GetResource(netlinkurl); + if (!result) + return $"Could not load ({result.Message})"; + return result; } private IResourceFactory GetFactoryFor(AudioType audioType) { foreach (var fac in factories) - if (fac.FactoryFor == audioType) return fac; + if (fac != DefaultFactorty && fac.FactoryFor == audioType) return fac; return DefaultFactorty; } private IResourceFactory GetFactoryFor(string uri) { foreach (var fac in factories) - if (fac.MatchLink(uri)) return fac; + if (fac != DefaultFactorty && fac.MatchLink(uri)) return fac; return DefaultFactorty; } public void AddFactory(IResourceFactory factory) { factories.Add(factory); + + // register factory command node + var playCommand = new PlayCommand(factory.FactoryFor); + CommandNode.AddCommand(factory.SubCommandName, playCommand.Command); } - public string RestoreLink(PlayData data) => RestoreLink(data.Resource); public string RestoreLink(AudioResource res) { IResourceFactory factory = GetFactoryFor(res.AudioType); @@ -128,5 +120,28 @@ public void Dispose() foreach (var fac in factories) fac.Dispose(); } + + sealed class PlayCommand + { + public BotCommand Command { get; } + private AudioType audioType; + private static readonly MethodInfo playMethod = typeof(PlayCommand).GetMethod(nameof(PropagiatePlay)); + + public PlayCommand(AudioType audioType) + { + this.audioType = audioType; + var builder = new CommandBuildInfo( + this, + playMethod, + new CommandAttribute(CommandRights.Private, string.Empty), + null); + Command = new BotCommand(builder); + } + + public string PropagiatePlay(ExecutionInformation info, string parameter) + { + return info.Session.Bot.PlayManager.Play(info.Session.Client, parameter, audioType); + } + } } } diff --git a/TS3AudioBot/ResourceFactories/SoundcloudFactory.cs b/TS3AudioBot/ResourceFactories/SoundcloudFactory.cs index 10dbacaf..0961b3d8 100644 --- a/TS3AudioBot/ResourceFactories/SoundcloudFactory.cs +++ b/TS3AudioBot/ResourceFactories/SoundcloudFactory.cs @@ -18,45 +18,45 @@ namespace TS3AudioBot.ResourceFactories { using System; using System.Collections.Generic; + using System.Globalization; using System.Text.RegularExpressions; - using System.Web.Script.Serialization; using Helper; public sealed class SoundcloudFactory : IResourceFactory { - private JavaScriptSerializer jsonParser; - + public string SubCommandName => "soundcloud"; public AudioType FactoryFor => AudioType.Soundcloud; public string SoundcloudClientID { get; private set; } public SoundcloudFactory() { - jsonParser = new JavaScriptSerializer(); SoundcloudClientID = "a9dd3403f858e105d7e266edc162a0c5"; } public bool MatchLink(string link) => Regex.IsMatch(link, @"^https?\:\/\/(www\.)?soundcloud\."); - public RResultCode GetResource(string link, out AudioResource resource) + public R GetResource(string link) { string jsonResponse; var uri = new Uri($"https://api.soundcloud.com/resolve.json?url={Uri.EscapeUriString(link)}&client_id={SoundcloudClientID}"); if (!WebWrapper.DownloadString(out jsonResponse, uri)) - { - resource = null; - return RResultCode.ScInvalidLink; - } + return RResultCode.ScInvalidLink.ToString(); var parsedDict = ParseJson(jsonResponse); int id = (int)parsedDict["id"]; string title = (string)parsedDict["title"]; - return GetResourceById(id.ToString(), title, out resource); + return GetResourceById(new AudioResource(id.ToString(CultureInfo.InvariantCulture), title, AudioType.Soundcloud)); } - public RResultCode GetResourceById(string id, string name, out AudioResource resource) + public R GetResourceById(AudioResource resource) { - string finalRequest = $"https://api.soundcloud.com/tracks/{id}/stream?client_id={SoundcloudClientID}"; - resource = new SoundcloudResource(id, name, finalRequest); - return RResultCode.Success; + if (resource.ResourceTitle == null) + { + string link = RestoreLink(resource.ResourceId); + return GetResource(link); // TODO: rework recursive request call here (care endless loop) + } + + string finalRequest = $"https://api.soundcloud.com/tracks/{resource.ResourceId}/stream?client_id={SoundcloudClientID}"; + return new PlayResource(finalRequest, resource); } public string RestoreLink(string id) @@ -69,31 +69,8 @@ public string RestoreLink(string id) return (string)parsedDict["permalink_url"]; } - private Dictionary ParseJson(string jsonResponse) => (Dictionary)jsonParser.DeserializeObject(jsonResponse); - - public void PostProcess(PlayData data, out bool abortPlay) - { - abortPlay = false; - } + private Dictionary ParseJson(string jsonResponse) => (Dictionary)Util.Serializer.DeserializeObject(jsonResponse); public void Dispose() { } } - - public sealed class SoundcloudResource : AudioResource - { - public override AudioType AudioType => AudioType.Soundcloud; - - public string ResourceURL { get; private set; } - - public SoundcloudResource(string id, string name, string url) - : base(id, name) - { - ResourceURL = url; - } - - public override string Play() - { - return ResourceURL; - } - } } diff --git a/TS3AudioBot/ResourceFactories/TwitchFactory.cs b/TS3AudioBot/ResourceFactories/TwitchFactory.cs index a9a856fc..11644653 100644 --- a/TS3AudioBot/ResourceFactories/TwitchFactory.cs +++ b/TS3AudioBot/ResourceFactories/TwitchFactory.cs @@ -18,47 +18,42 @@ namespace TS3AudioBot.ResourceFactories { using System; using System.Collections.Generic; + using System.Globalization; using System.Text.RegularExpressions; - using System.Web.Script.Serialization; using Helper; public sealed class TwitchFactory : IResourceFactory { - private Regex twitchMatch = new Regex(@"^(https?://)?(www\.)?twitch\.tv/(\w+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private Regex m3u8ExtMatch = new Regex(@"#([\w-]+)(:(([\w-]+)=(""[^""]*""|[^,]+),?)*)?", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private JavaScriptSerializer jsonParser; + private Regex twitchMatch = new Regex(@"^(https?://)?(www\.)?twitch\.tv/(\w+)", Util.DefaultRegexConfig); + private Regex m3u8ExtMatch = new Regex(@"#([\w-]+)(:(([\w-]+)=(""[^""]*""|[^,]+),?)*)?", Util.DefaultRegexConfig); + + public string SubCommandName => "twitch"; + public AudioType FactoryFor => AudioType.Twitch; + public string TwitchClientID { get; private set; } public TwitchFactory() { - jsonParser = new JavaScriptSerializer(); + TwitchClientID = "t9nlhlxnfux3gk2d6z1p093rj2c71i3"; } - public AudioType FactoryFor => AudioType.Twitch; - - public RResultCode GetResource(string url, out AudioResource resource) + public R GetResource(string url) { var match = twitchMatch.Match(url); if (!match.Success) - { - resource = null; - return RResultCode.TwitchInvalidUrl; - } - return GetResourceById(match.Groups[3].Value, null, out resource); + return RResultCode.TwitchInvalidUrl.ToString(); + return GetResourceById(new AudioResource(match.Groups[3].Value, null, AudioType.Twitch)); } - public RResultCode GetResourceById(string id, string name, out AudioResource resource) + public R GetResourceById(AudioResource resource) { - var channel = id; + var channel = resource.ResourceId; // request api token string jsonResponse; - if (!WebWrapper.DownloadString(out jsonResponse, new Uri($"http://api.twitch.tv/api/channels/{channel}/access_token"))) - { - resource = null; - return RResultCode.NoConnection; - } + if (!WebWrapper.DownloadString(out jsonResponse, new Uri($"http://api.twitch.tv/api/channels/{channel}/access_token"), new Tuple("Client-ID", TwitchClientID))) + return RResultCode.NoConnection.ToString(); - var jsonDict = (Dictionary)jsonParser.DeserializeObject(jsonResponse); + var jsonDict = (Dictionary)Util.Serializer.DeserializeObject(jsonResponse); // request m3u8 file var token = Uri.EscapeUriString(jsonDict["token"].ToString()); @@ -67,10 +62,7 @@ public RResultCode GetResourceById(string id, string name, out AudioResource res var random = 4; string m3u8; if (!WebWrapper.DownloadString(out m3u8, new Uri($"http://usher.twitch.tv/api/channel/hls/{channel}.m3u8?player=twitchweb&&token={token}&sig={sig}&allow_audio_only=true&allow_source=true&type=any&p={random}"))) - { - resource = null; - return RResultCode.NoConnection; - } + return RResultCode.NoConnection.ToString(); // parse m3u8 file var dataList = new List(); @@ -78,10 +70,7 @@ public RResultCode GetResourceById(string id, string name, out AudioResource res { var header = reader.ReadLine(); if (string.IsNullOrEmpty(header) || header != "#EXTM3U") - { - resource = null; - return RResultCode.TwitchMalformedM3u8File; - } + return RResultCode.TwitchMalformedM3u8File.ToString(); while (true) { @@ -95,68 +84,62 @@ public RResultCode GetResourceById(string id, string name, out AudioResource res switch (match.Groups[1].Value) { - case "EXT-X-TWITCH-INFO": break; // Ignore twitch info line - case "EXT-X-MEDIA": - string streamInfo = reader.ReadLine(); - Match infoMatch; - if (string.IsNullOrEmpty(streamInfo) || - !(infoMatch = m3u8ExtMatch.Match(streamInfo)).Success || - infoMatch.Groups[1].Value != "EXT-X-STREAM-INF") - { - resource = null; - return RResultCode.TwitchMalformedM3u8File; - } - - var streamData = new StreamData(); - // #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=128000,CODECS="mp4a.40.2",VIDEO="audio_only" - for (int i = 0; i < infoMatch.Groups[3].Captures.Count; i++) - { - string key = infoMatch.Groups[4].Captures[i].Value.ToUpper(); - string value = infoMatch.Groups[5].Captures[i].Value; - - switch (key) + case "EXT-X-TWITCH-INFO": break; // Ignore twitch info line + case "EXT-X-MEDIA": + string streamInfo = reader.ReadLine(); + Match infoMatch; + if (string.IsNullOrEmpty(streamInfo) || + !(infoMatch = m3u8ExtMatch.Match(streamInfo)).Success || + infoMatch.Groups[1].Value != "EXT-X-STREAM-INF") + return RResultCode.TwitchMalformedM3u8File.ToString(); + + var streamData = new StreamData(); + // #EXT-X-STREAM-INF:PROGRAM-ID=1,BANDWIDTH=128000,CODECS="mp4a.40.2",VIDEO="audio_only" + for (int i = 0; i < infoMatch.Groups[3].Captures.Count; i++) { - case "BANDWIDTH": streamData.Bandwidth = int.Parse(value); break; - case "CODECS": streamData.Codec = TextUtil.StripQuotes(value); break; - case "VIDEO": - StreamQuality quality; - if (Enum.TryParse(TextUtil.StripQuotes(value), out quality)) - streamData.QualityType = quality; - else - streamData.QualityType = StreamQuality.unknown; - break; + string key = infoMatch.Groups[4].Captures[i].Value.ToUpper(CultureInfo.InvariantCulture); + string value = infoMatch.Groups[5].Captures[i].Value; + + switch (key) + { + case "BANDWIDTH": streamData.Bandwidth = int.Parse(value, CultureInfo.InvariantCulture); break; + case "CODECS": streamData.Codec = TextUtil.StripQuotes(value); break; + case "VIDEO": + StreamQuality quality; + if (Enum.TryParse(TextUtil.StripQuotes(value), out quality)) + streamData.QualityType = quality; + else + streamData.QualityType = StreamQuality.unknown; + break; + } } - } - streamData.Url = reader.ReadLine(); - dataList.Add(streamData); - break; - default: break; + streamData.Url = reader.ReadLine(); + dataList.Add(streamData); + break; + default: break; } } } - resource = new TwitchResource(channel, name ?? $"Twitch channel: {channel}", dataList); - return dataList.Count > 0 ? RResultCode.Success : RResultCode.TwitchNoStreamsExtracted; + // Validation Process + + if (dataList.Count <= 0) + return RResultCode.TwitchNoStreamsExtracted.ToString(); + + int codec = SelectStream(dataList); + if (codec < 0) + return "The stream has no audio_only version."; + + return new PlayResource(dataList[codec].Url, resource.ResourceTitle != null ? resource : resource.WithName($"Twitch channel: {channel}")); } public bool MatchLink(string uri) => twitchMatch.IsMatch(uri); - public void PostProcess(PlayData data, out bool abortPlay) + private int SelectStream(List list) { - var twResource = (TwitchResource)data.Resource; - // selecting the best stream - int autoselectIndex = twResource.AvailableStreams.FindIndex(s => s.QualityType == StreamQuality.audio_only); - if (autoselectIndex != -1) - { - twResource.Selected = autoselectIndex; - abortPlay = false; - return; - } - - // TODO add response like youtube - data.Session.Write("The stream has no audio_only version."); - abortPlay = true; + int autoselectIndex = list.FindIndex(s => s.QualityType == StreamQuality.audio_only); + return autoselectIndex; } public string RestoreLink(string id) => "http://www.twitch.tv/" + id; @@ -166,10 +149,10 @@ public void Dispose() { } public sealed class StreamData { - public StreamQuality QualityType; - public int Bandwidth; - public string Codec; - public string Url; + public StreamQuality QualityType { get; set; } + public int Bandwidth { get; set; } + public string Codec { get; set; } + public string Url { get; set; } } public enum StreamQuality @@ -182,25 +165,4 @@ public enum StreamQuality mobile, audio_only, } - - public sealed class TwitchResource : AudioResource - { - public List AvailableStreams { get; private set; } - public int Selected { get; set; } - - public override AudioType AudioType => AudioType.Twitch; - - public TwitchResource(string channel, string name, List availableStreams) : base(channel, name) - { - AvailableStreams = availableStreams; - } - - public override string Play() - { - if (Selected < 0 && Selected >= AvailableStreams.Count) - return null; - Log.Write(Log.Level.Debug, "YT Playing: {0}", AvailableStreams[Selected]); - return AvailableStreams[Selected].Url; - } - } } diff --git a/TS3AudioBot/ResourceFactories/YoutubeFactory.cs b/TS3AudioBot/ResourceFactories/YoutubeFactory.cs index 58fb0700..3064c04c 100644 --- a/TS3AudioBot/ResourceFactories/YoutubeFactory.cs +++ b/TS3AudioBot/ResourceFactories/YoutubeFactory.cs @@ -19,42 +19,49 @@ namespace TS3AudioBot.ResourceFactories using System; using System.Collections.Generic; using System.Collections.Specialized; + using System.ComponentModel; + using System.Diagnostics; + using System.Globalization; + using System.IO; using System.Text; using System.Text.RegularExpressions; using System.Web; using Helper; - using CommandSystem; - public sealed class YoutubeFactory : IResourceFactory + + public sealed class YoutubeFactory : IResourceFactory, IPlaylistFactory { - private Regex idMatch = new Regex(@"((&|\?)v=|youtu\.be\/)([a-zA-Z0-9\-_]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private Regex linkMatch = new Regex(@"^(https?\:\/\/)?(www\.|m\.)?(youtube\.|youtu\.be)", RegexOptions.IgnoreCase | RegexOptions.Compiled); + private static readonly Regex idMatch = new Regex(@"((&|\?)v=|youtu\.be\/)([a-zA-Z0-9\-_]+)", Util.DefaultRegexConfig); + private static readonly Regex linkMatch = new Regex(@"^(https?\:\/\/)?(www\.|m\.)?(youtube\.|youtu\.be)", Util.DefaultRegexConfig); + private static readonly Regex listMatch = new Regex(@"(&|\?)list=([\w-]+)", Util.DefaultRegexConfig); + public string SubCommandName => "youtube"; public AudioType FactoryFor => AudioType.Youtube; - public YoutubeFactory() { } + private YoutubeFactoryData data; + + public YoutubeFactory(YoutubeFactoryData yfd) + { + data = yfd; + } - public bool MatchLink(string link) => linkMatch.IsMatch(link); + public bool MatchLink(string link) => linkMatch.IsMatch(link) || listMatch.IsMatch(link); + bool IResourceFactory.MatchLink(string link) => linkMatch.IsMatch(link); + bool IPlaylistFactory.MatchLink(string link) => listMatch.IsMatch(link); - public RResultCode GetResource(string ytLink, out AudioResource result) + public R GetResource(string ytLink) { Match matchYtId = idMatch.Match(ytLink); if (!matchYtId.Success) - { - result = null; - return RResultCode.YtIdNotFound; - } - return GetResourceById(matchYtId.Groups[3].Value, null, out result); + return RResultCode.YtIdNotFound.ToString(); + return GetResourceById(new AudioResource(matchYtId.Groups[3].Value, null, AudioType.Youtube)); } - public RResultCode GetResourceById(string ytID, string name, out AudioResource result) + public R GetResourceById(AudioResource resource) { string resulthtml; - if (!WebWrapper.DownloadString(out resulthtml, new Uri($"http://www.youtube.com/get_video_info?video_id={ytID}&el=info"))) - { - result = null; - return RResultCode.NoConnection; - } + if (!WebWrapper.DownloadString(out resulthtml, new Uri($"http://www.youtube.com/get_video_info?video_id={resource.ResourceId}&el=info"))) + return RResultCode.NoConnection.ToString(); var videoTypes = new List(); NameValueCollection dataParse = HttpUtility.ParseQueryString(resulthtml); @@ -81,9 +88,9 @@ public RResultCode GetResourceById(string ytID, string name, out AudioResource r continue; var vt = new VideoData(); - vt.link = vLink; - vt.codec = GetCodec(vType); - vt.qualitydesciption = vQuality; + vt.Link = vLink; + vt.Codec = GetCodec(vType); + vt.Qualitydesciption = vQuality; videoTypes.Add(vt); } } @@ -102,9 +109,9 @@ public RResultCode GetResourceById(string ytID, string name, out AudioResource r continue; bool audioOnly = false; - if (vType.StartsWith("video/")) + if (vType.StartsWith("video/", StringComparison.Ordinal)) continue; - else if (vType.StartsWith("audio/")) + else if (vType.StartsWith("audio/", StringComparison.Ordinal)) audioOnly = true; string vLink = videoparse["url"]; @@ -112,116 +119,80 @@ public RResultCode GetResourceById(string ytID, string name, out AudioResource r continue; var vt = new VideoData(); - vt.codec = GetCodec(vType); - vt.qualitydesciption = vType; - vt.link = vLink; + vt.Codec = GetCodec(vType); + vt.Qualitydesciption = vType; + vt.Link = vLink; if (audioOnly) - vt.audioOnly = true; + vt.AudioOnly = true; else - vt.videoOnly = true; + vt.VideoOnly = true; videoTypes.Add(vt); } } - string finalName = name ?? dataParse["title"] ?? $""; - var ytResult = new YoutubeResource(ytID, finalName, videoTypes); - result = ytResult; - return ytResult.AvailableTypes.Count > 0 ? RResultCode.Success : RResultCode.YtNoVideosExtracted; + // Validation Process + + if (videoTypes.Count <= 0) + return RResultCode.YtNoVideosExtracted.ToString(); + + int codec = SelectStream(videoTypes); + if (codec < 0) + return "No playable codec found"; + + var result = ValidateMedia(videoTypes[codec]); + if (!result) + { + if (string.IsNullOrWhiteSpace(data.youtubedlpath)) + return result.Message; + + return YoutubeDlWrapped(resource); + } + + return new PlayResource(videoTypes[codec].Link, resource.ResourceTitle != null ? resource : resource.WithName(dataParse["title"] ?? $"")); } public string RestoreLink(string id) => "https://youtu.be/" + id; - public void PostProcess(PlayData data, out bool abortPlay) + private int SelectStream(List list) { - YoutubeResource ytResource = (YoutubeResource)data.Resource; - #if DEBUG StringBuilder dbg = new StringBuilder("YT avail codecs: "); - foreach (var yd in ytResource.AvailableTypes) - dbg.Append(yd.qualitydesciption).Append(" @ ").Append(yd.codec).Append(", "); + foreach (var yd in list) + dbg.Append(yd.Qualitydesciption).Append(" @ ").Append(yd.Codec).Append(", "); Log.Write(Log.Level.Debug, dbg.ToString()); #endif - var availList = ytResource.AvailableTypes; - int autoselectIndex = availList.FindIndex(t => t.codec == VideoCodec.M4A); + int autoselectIndex = list.FindIndex(t => t.Codec == VideoCodec.M4A); if (autoselectIndex == -1) - autoselectIndex = availList.FindIndex(t => t.audioOnly); - if (autoselectIndex != -1) - { - ytResource.Selected = autoselectIndex; - abortPlay = !ValidateMedia(data.Session, ytResource); - return; - } + autoselectIndex = list.FindIndex(t => t.AudioOnly); + if (autoselectIndex == -1) + autoselectIndex = list.FindIndex(t => !t.VideoOnly); - StringBuilder strb = new StringBuilder(); - strb.AppendLine("\nMultiple formats found please choose one with !f "); - int count = 0; - foreach (var videoType in ytResource.AvailableTypes) - strb.Append("[") - .Append(count++) - .Append("] ") - .Append(videoType.codec.ToString()) - .Append(" @ ") - .AppendLine(videoType.qualitydesciption); - - abortPlay = true; - data.Session.Write(strb.ToString()); - data.Session.UserResource = data; - data.Session.SetResponse(ResponseYoutube, null); + return autoselectIndex; } - private static bool ResponseYoutube(ExecutionInformation info) + private static R ValidateMedia(VideoData media) { - string[] command = info.TextMessage.Message.SplitNoEmpty(' '); - if (command[0] != "!f") - return false; - if (command.Length != 2) - return true; - int entry; - if (int.TryParse(command[1], out entry)) - { - PlayData data = info.Session.UserResource; - if (data == null || data.Resource as YoutubeResource == null) - { - info.Session.Write("An unexpected error with the ytresource occured: null."); - return true; - } - YoutubeResource ytResource = (YoutubeResource)data.Resource; - if (entry < 0 || entry >= ytResource.AvailableTypes.Count) - return true; - ytResource.Selected = entry; - if (ValidateMedia(info.Session, ytResource)) - info.Session.Bot.FactoryManager.Play(data); - } - return true; - } + var vcode = WebWrapper.GetResponse(new Uri(media.Link), TimeSpan.FromSeconds(1)); - private static bool ValidateMedia(BotSession session, YoutubeResource resource) - { - switch (ValidateMedia(resource)) + switch (vcode) { - case ValidateCode.Ok: return true; - case ValidateCode.Restricted: session.Write("The video cannot be played due to youtube restrictions."); return false; - case ValidateCode.Timeout: session.Write("No connection could be established to youtube. Please try again later."); return false; - case ValidateCode.UnknownError: session.Write("Unknown error occoured"); return false; + case ValidateCode.Ok: return R.OkR; + case ValidateCode.Restricted: return "The video cannot be played due to youtube restrictions."; + case ValidateCode.Timeout: return "No connection could be established to youtube. Please try again later."; + case ValidateCode.UnknownError: return "Unknown error occoured"; default: throw new InvalidOperationException(); } } - private static ValidateCode ValidateMedia(YoutubeResource resource) - { - var media = resource.AvailableTypes[resource.Selected]; - return WebWrapper.GetResponse(new Uri(media.link), TimeSpan.FromSeconds(1)); - } - private static VideoCodec GetCodec(string type) { - string lowtype = type.ToLower(); + string lowtype = type.ToLower(CultureInfo.InvariantCulture); bool audioOnly = false; string codecSubStr; - if (lowtype.StartsWith("video/")) + if (lowtype.StartsWith("video/", StringComparison.Ordinal)) codecSubStr = lowtype.Substring("video/".Length); - else if (lowtype.StartsWith("audio/")) + else if (lowtype.StartsWith("audio/", StringComparison.Ordinal)) { codecSubStr = lowtype.Substring("audio/".Length); audioOnly = true; @@ -249,41 +220,211 @@ private static VideoCodec GetCodec(string type) } } - public void Dispose() { } - } + public R GetPlaylist(string url) + { + Match matchYtId = listMatch.Match(url); + if (!matchYtId.Success) + return "Could not extract a playlist id"; - public sealed class VideoData - { - public string link; - public string qualitydesciption; - public VideoCodec codec; - public bool audioOnly = false; - public bool videoOnly = false; + string id = matchYtId.Groups[2].Value; + var plist = new Playlist(id); - public override string ToString() => $"{qualitydesciption} @ {codec} - {link}"; - } + string nextToken = null; + do + { + var queryString = + new Uri("https://www.googleapis.com/youtube/v3/playlistItems" + + "?part=contentDetails,snippet" + + "&maxResults=50" + + "&playlistId=" + id + + "&fields=" + Uri.EscapeDataString("items(contentDetails/videoId,snippet/title),nextPageToken") + + (nextToken != null ? ("&pageToken=" + nextToken) : string.Empty) + + "&key=" + data.apiKey); + + string response; + if (!WebWrapper.DownloadString(out response, queryString)) + return "Web response error"; + var parsed = Util.Serializer.Deserialize(response); + var videoItems = parsed.items; + YoutubePlaylistItem[] itemBuffer = new YoutubePlaylistItem[videoItems.Length]; + for (int i = 0; i < videoItems.Length; i++) + { + itemBuffer[i] = new YoutubePlaylistItem(new AudioResource( + videoItems[i].contentDetails.videoId, + videoItems[i].snippet.title, + AudioType.Youtube)); + } - public sealed class YoutubeResource : AudioResource - { - public List AvailableTypes { get; } - public int Selected { get; set; } +#if getlength + queryString = new Uri($"https://www.googleapis.com/youtube/v3/videos?id={string.Join(",", itemBuffer.Select(item => item.Resource.ResourceId))}&part=contentDetails&key={data.apiKey}"); + if (!WebWrapper.DownloadString(out response, queryString)) + return "Web response error"; + var parsedTime = (Dictionary)Util.Serializer.DeserializeObject(response); + var videoDicts = ((object[])parsedTime["items"]).Cast>().ToArray(); + for (int i = 0; i < videoDicts.Length; i++) + itemBuffer[i].Length = XmlConvert.ToTimeSpan((string)(((Dictionary)videoDicts[i]["contentDetails"])["duration"])); +#endif + + plist.AddRange(itemBuffer); - public override AudioType AudioType => AudioType.Youtube; + nextToken = parsed.nextPageToken; + } while (nextToken != null); + + return plist; + } - public YoutubeResource(string ytId, string youtubeName, List availableTypes) - : base(ytId, youtubeName) + public static string LoadAlternative(string id) { - AvailableTypes = availableTypes; - Selected = 0; + string resulthtml; + if (!WebWrapper.DownloadString(out resulthtml, new Uri($"https://www.youtube.com/watch?v={id}&gl=US&hl=en&has_verified=1&bpctr=9999999999"))) + return "No con"; + + int indexof = resulthtml.IndexOf("ytplayer.config ="); + int ptr = indexof; + while (resulthtml[ptr] != '{') ptr++; + int start = ptr; + int stackcnt = 1; + while (stackcnt > 0) + { + ptr++; + if (resulthtml[ptr] == '{') stackcnt++; + else if (resulthtml[ptr] == '}') stackcnt--; + } + + var jsonobj = Util.Serializer.DeserializeObject(resulthtml.Substring(start, ptr - start + 1)); + var args = GetDictVal(jsonobj, "args"); + var url_encoded_fmt_stream_map = GetDictVal(args, "url_encoded_fmt_stream_map"); + + string[] enco_split = ((string)url_encoded_fmt_stream_map).Split(','); + foreach (var single_enco in enco_split) + { + var lis = HttpUtility.ParseQueryString(single_enco); + + var signature = lis["s"]; + var url = lis["url"]; + if (!url.Contains("signature")) + url += "&signature=" + signature; + return url; + } + return "No match"; + } + + private static object GetDictVal(object dict, string field) => (dict as Dictionary)?[field]; + + public R YoutubeDlWrapped(AudioResource resource) + { + string title = null; + string url = null; + + var ytdlPath = DetectYoutubeDl(resource.ResourceId); + if (ytdlPath == null) + return "Youtube-Dl could not be found. The video cannot be played due to youtube restrictions"; + + Log.Write(Log.Level.Debug, "YT Ruined!"); + try + { + using (Process tmproc = new Process()) + { + tmproc.StartInfo.FileName = "python"; + tmproc.StartInfo.Arguments = $"\"{ytdlPath}\" --get-title --get-url --id {resource.ResourceId}"; + tmproc.StartInfo.UseShellExecute = false; + tmproc.StartInfo.CreateNoWindow = true; + tmproc.StartInfo.RedirectStandardOutput = true; + tmproc.StartInfo.RedirectStandardError = true; + tmproc.Start(); + tmproc.WaitForExit(10000); + + using (StreamReader reader = tmproc.StandardError) + { + string result = reader.ReadToEnd(); + if (!string.IsNullOrEmpty(result)) + return result; + } + + title = tmproc.StandardOutput.ReadLine(); + url = tmproc.StandardOutput.ReadLine(); + } + } + catch (Win32Exception) { return "Failed to run youtube-dl"; } + + if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(url)) + return "No youtube-dl response"; + + Log.Write(Log.Level.Debug, "YT Saved!"); + return new PlayResource(url, resource.WithName(title)); + } + + private string DetectYoutubeDl(string id) + { + // Default path youtube-dl is suggesting to install + const string defaultYtDlPath = "/usr/local/bin/youtube-dl"; + if (File.Exists(defaultYtDlPath)) + return defaultYtDlPath; + + // Example: /home/teamspeak/youtube-dl where 'youtube-dl' is the binary + string fullCustomPath = Path.GetFullPath(data.youtubedlpath); + if (File.Exists(fullCustomPath)) + return fullCustomPath; + + // Example: /home/teamspeak where the binary 'youtube-dl' lies in ./teamspeak/ + string fullCustomPathWithoutFile = Path.Combine(fullCustomPath, "youtube-dl"); + if (File.Exists(fullCustomPathWithoutFile)) + return fullCustomPathWithoutFile; + + // Example: /home/teamspeak/youtube-dl where 'youtube-dl' is the github project folder + string fullCustomPathGhProject = Path.Combine(fullCustomPath, "youtube_dl", "__main__.py"); + if (File.Exists(fullCustomPathGhProject)) + return fullCustomPathGhProject; + + return null; } - public override string Play() + public void Dispose() { } + +#pragma warning disable CS0649 + class JSON_PlaylistItems { - if (Selected < 0 && Selected >= AvailableTypes.Count) - return null; - Log.Write(Log.Level.Debug, "YT Playing: {0}", AvailableTypes[Selected]); - return AvailableTypes[Selected].link; + public string nextPageToken; + public Item[] items; + + public class Item + { + public ContentDetails contentDetails; + public Snippet snippet; + + public class ContentDetails + { + public string videoId; + } + + public class Snippet + { + public string title; + } + } } +#pragma warning restore CS0649 + } + +#pragma warning disable CS0649 + public struct YoutubeFactoryData + { + [Info("a youtube apiv3 'Browser' type key", "AIzaSyBOqG5LUbGSkBfRUoYfUUea37-5xlEyxNs")] + public string apiKey; + [Info("absolute or relative path to the youtube-dl binary or repository", "")] + public string youtubedlpath; + } +#pragma warning restore CS0649 + + public sealed class VideoData + { + public string Link { get; set; } + public string Qualitydesciption { get; set; } + public VideoCodec Codec { get; set; } + public bool AudioOnly { get; set; } = false; + public bool VideoOnly { get; set; } = false; + + public override string ToString() => $"{Qualitydesciption} @ {Codec} - {Link}"; } public enum VideoCodec diff --git a/TS3AudioBot/SessionManager.cs b/TS3AudioBot/SessionManager.cs index 98b7e0df..3a83e229 100644 --- a/TS3AudioBot/SessionManager.cs +++ b/TS3AudioBot/SessionManager.cs @@ -19,62 +19,48 @@ namespace TS3AudioBot using System; using System.Collections.Generic; using System.Linq; - using TS3Query; - using TS3Query.Messages; + using Helper; + using TS3Client.Messages; - public class SessionManager : MarshalByRefObject + public class SessionManager { - public BotSession DefaultSession { get; internal set; } - private readonly List openSessions; + private readonly List openSessions = new List(); - public SessionManager() - { - openSessions = new List(); - } + public SessionManager() { } - public BotSession CreateSession(MainBot bot, ushort invokerId) + public R CreateSession(MainBot bot, ushort invokerId) { if (bot == null) throw new ArgumentNullException(nameof(bot)); - if (ExistsSession(invokerId)) - return GetSession(MessageTarget.Private, invokerId); + var result = GetSession(invokerId); + if (result) return result.Value; + ClientData client = bot.QueryConnection.GetClientById(invokerId); if (client == null) - throw new SessionManagerException("Could not find the requested client."); - var newSession = new PrivateSession(bot, client); + return "Could not find the requested client."; + + Log.Write(Log.Level.Debug, "SM User {0} created session with the bot", client.NickName); + var newSession = new UserSession(bot, client); openSessions.Add(newSession); return newSession; } public bool ExistsSession(ushort invokerId) { - return openSessions.Any((ps) => ps.Client.ClientId == invokerId); + return openSessions.Any((ps) => ps.ClientCached.ClientId == invokerId); } - public BotSession GetSession(MessageTarget target, ushort invokerId) + public R GetSession(ushort invokerId) { - if (target == MessageTarget.Server) - return DefaultSession; - return openSessions.FirstOrDefault((bs) => bs.Client.ClientId == invokerId) ?? DefaultSession; + var session = openSessions.FirstOrDefault((bs) => bs.ClientCached.ClientId == invokerId); + if (session == null) return "Session not found"; + else return session; } public void RemoveSession(ushort invokerId) { - openSessions.RemoveAll((ps) => ps.Client.ClientId == invokerId); + openSessions.RemoveAll((ps) => ps.ClientCached.ClientId == invokerId); } } - - - [Serializable] - public class SessionManagerException : Exception - { - public SessionManagerException() { } - public SessionManagerException(string message) : base(message) { } - public SessionManagerException(string message, Exception inner) : base(message, inner) { } - protected SessionManagerException( - System.Runtime.Serialization.SerializationInfo info, - System.Runtime.Serialization.StreamingContext context) : base(info, context) - { } - } } diff --git a/TS3AudioBot/TS3AudioBot.csproj b/TS3AudioBot/TS3AudioBot.csproj index 1c091f37..07628a04 100644 --- a/TS3AudioBot/TS3AudioBot.csproj +++ b/TS3AudioBot/TS3AudioBot.csproj @@ -26,7 +26,9 @@ prompt 4 false - MinimumRecommendedRules.ruleset + + + true AnyCPU @@ -40,7 +42,17 @@ + + favicon.ico + + + TS3AudioBot.MainBot + + + ..\packages\HtmlAgilityPack.1.4.9\lib\Net45\HtmlAgilityPack.dll + True + @@ -50,6 +62,7 @@ + @@ -76,16 +89,19 @@ + + + + - @@ -113,23 +129,34 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + {0eb99e9d-87e5-4534-a100-55d231c2b6a6} + TS3Client + - +
+ +
+ \ No newline at end of file diff --git a/TS3AudioBot/WebInterface/scripts.js b/TS3AudioBot/WebInterface/scripts.js new file mode 100644 index 00000000..e0834c66 --- /dev/null +++ b/TS3AudioBot/WebInterface/scripts.js @@ -0,0 +1,189 @@ +/// +$(document).ready(main); + +//+ main page div/section +var content = null; +//+ playcontrols event handler +var ev_playdata = null; +var playcontrols = null; +var playdata = null; +var playticker = null; +//+ devupdate event handler +var ev_devupdate = null; +var lastreq = null; + +// MAIN FUNCTIONS + +function main() { + content = $("#content"); + $("nav a").click(navbar_click); + content_loaded(); + + if (ev_devupdate !== null) { + ev_devupdate.close(); + ev_devupdate = null; + } + if ($("#devupdate").length !== 0) { + ev_devupdate = new EventSource("devupdate"); + ev_devupdate.onmessage = update_site; + } +} + +function navbar_click(event) { + event.preventDefault(); + $(this).blur(); + var newSite = $(this).attr("href"); + var query = get_query(newSite.substr(newSite.indexOf('?') + 1)); + load("/" + query["page"]); + window.history.pushState('mainpage', '', newSite); +} + +function load(page) { + lastreq = page; + content.load(page, content_loaded); +} + +// SPECIAL EVENT HANDLER + +function content_loaded() { + // History handler + $("button[form='searchquery']").remove(); + $("#searchquery :input").each(function () { + $(this).bind('keyup change click', history_search); + }); + + // PlayControls + if (ev_playdata !== null) { + ev_playdata.close(); + ev_playdata = null; + } + if (playticker !== null) { + clearInterval(playticker); + playticker = null; + } + + var handler = $("#playhandler"); + if (handler.length !== 0) { + handler.load("/playcontrols", function () { + // gather all controls + playcontrols = {}; + playcontrols.mute = handler.find("#playctrlmute"); + playcontrols.volume = handler.find("input[name='volume']"); + playcontrols.prev = handler.find("#playctrlprev"); + playcontrols.play = handler.find("#playctrlplay"); + playcontrols.next = handler.find("#playctrlnext"); + playcontrols.loop = handler.find("#playctrlloop"); + playcontrols.position = handler.find("input[name='position']"); + + // register sse + ev_playdata = new EventSource("playdata"); + ev_playdata.onmessage = update_song; + playticker = setInterval(song_position_tick, 1000); + + // register events + playcontrols.mute.click(function () { $.get("/control?op=volume&volume=0"); }); // todo on/off + playcontrols.volume.on("input", function () { $.get("/control?op=volume&volume=" + value_to_logarithmic(this.value).toFixed(0)); }); + playcontrols.prev.click(function () { $.get("/control?op=prev"); }); + playcontrols.play.click(function () { $.get("/control?op=play"); }); + playcontrols.next.click(function () { $.get("/control?op=next"); }); + playcontrols.loop.click(function () { $.get("/control?op=loop"); }); // todo on/off + playcontrols.position.on("change", function () { $.get("/control?op=seek&pos=" + this.value); }); + }); + } +} + +function update_site(event) { + if (event.data === "update") { + load(lastreq); + } +} + +// HELPER + +const slmax = 7.0; +const scale = 100.0; + +function value_to_logarithmic(val) { + if (val < 0) val = 0; + else if (val > slmax) val = slmax; + + return (1.0 / Math.log10(10 - val) - 1) * (scale / (1.0 / Math.log10(10 - slmax) - 1)); +} + +function logarithmic_to_value(val) { + if (val < 0) val = 0; + else if (val > scale) val = scale; + + return 10 - Math.pow(10, 1.0 / (val / (scale / (1.0 / Math.log10(10 - slmax) - 1)) + 1)); +} + +function get_query(url) { + var match, + search = /([^&=]+)=?([^&]*)/g, + decode = function (s) { return decodeURIComponent(s.replace(/\+/g, " ")); }, + urlParams = {}; + while (match = search.exec(url)) + urlParams[decode(match[1])] = decode(match[2]); + return urlParams; +} + +// PLAYCONTROLS + +function update_song(event) { + playdata = jQuery.parseJSON(event.data); + var jsSlider = playcontrols.position.get(0); + + if (playdata.hassong === true) { + playcontrols.volume.get(0).value = parseInt(logarithmic_to_value(playdata.volume)); + jsSlider.max = parseInt(playdata.length.TotalSeconds); + jsSlider.value = playdata.position.TotalSeconds; + } else { + jsSlider.max = 0; + jsSlider.value = 0; + } +} + +function song_position_tick() { + if (playdata.paused === false && playdata.hassong === true) { + var jsSlider = playcontrols.position.get(0); + if (parseInt(jsSlider.value) < playdata.length.TotalSeconds) { + jsSlider.value = parseInt(jsSlider.value) + 1; + } + } +} + +// HISTORY FUNCTIONS + +function history_search() { + var builder = {}; + $("#searchquery :input").each(function () { + var inp = $(this); + builder[inp.attr("name")] = inp.val(); + }); + + var requestQuery = jQuery.param(builder); + + $.get("/historysearch?" + requestQuery, fill_history); +} + +function fill_history(rawdata) { + var data = jQuery.parseJSON(rawdata); + hresult = $("#historylist tbody"); + hresult.empty(); + hresult.append( + "" + + "Id" + + "UserId" + + "Title" + + "Options" + + ""); + + for (var i = 0; i < data.length; i++) { + var elem = data[i]; + hresult.append( + "" + elem["id"] + + "" + elem["userid"] + + "" + elem["title"] + + "Options"); + } +} diff --git a/TS3AudioBot/WebInterface/styles.css b/TS3AudioBot/WebInterface/styles.css new file mode 100644 index 00000000..abae4454 --- /dev/null +++ b/TS3AudioBot/WebInterface/styles.css @@ -0,0 +1,296 @@ +/*
 */
+
+:root {
+    --top-off: 80px;
+    --left-off: 150px;
+    --bot-off: 25px;
+    --site-color: #B0171F;
+    --shadow: 3px 3px 3px 0px #050505;
+}
+
+/*Unpad stuff here*/
+html, body, header, nav, footer, section, label, .filledform {
+    margin: 0;
+    padding: 0;
+    border: 0;
+}
+
+html, body {
+    background-color: #050505;
+    color: #FF6103;
+    height: 100%;
+    width: 100%;
+    position: relative;
+}
+
+h2 {
+    text-align: center;
+}
+
+header {
+    color: white;
+    text-align: center;
+    position: fixed;
+    width: 100%;
+    height: var(--top-off);
+    z-index: 15;
+    cursor: default;
+}
+
+nav {
+    top: 0;
+    bottom: 0;
+    width: var(--left-off);
+    padding-top: var(--top-off);
+    position: absolute;
+    line-height: 2em;
+    background-color: #050505;
+    float: left;
+    z-index: 10;
+    font-family: Verdana;
+}
+
+section {
+    top: var(--top-off);
+    left: 0;
+    bottom: var(--bot-off);
+    right: 0;
+    margin-left: var(--left-off);
+    position: absolute;
+    background-color: #202020;
+    overflow-y: auto;
+    overflow-x: auto;
+    display: inline-block;
+    z-index: 5;
+}
+
+footer {
+    bottom: 0;
+    height: var(--bot-off);
+    width: 100%;
+    color: white;
+    clear: both;
+    text-align: center;
+    position: fixed;
+    z-index: 10;
+    cursor: default;
+}
+
+/* Common button apereance */
+
+.button {
+    background-color: var(--site-color);
+    border: none;
+    color: white;
+    text-decoration: none;
+    text-align: center;
+    font-size: 16px;
+    border-radius: 8px;
+    -moz-box-shadow: var(--shadow);
+    -webkit-box-shadow: var(--shadow);
+    box-shadow: var(--shadow);
+    padding: 10px 0px;
+    width: 90%;
+    display: inline-block;
+    margin: 0px 5% 0.4em;
+    cursor: pointer;
+}
+
+    .button:active {
+        -moz-box-shadow: 0px 0px 2px 0px #050505;
+        -webkit-box-shadow: 0px 0px 2px 0px #050505;
+        box-shadow: 0px 0px 2px 0px #050505;
+    }
+
+    .button:hover {
+        background-color: #ee745d;
+        color: black;
+    }
+
+label {
+    display: inline-block;
+    clear: left;
+    width: 40%;
+    position: relative;
+    text-align: right;
+    float: left;
+}
+
+.unselectable, nav > a {
+    -webkit-touch-callout: none;
+    -webkit-user-select: none;
+    -khtml-user-select: none;
+    -moz-user-select: none;
+    -ms-user-select: none;
+    user-select: none;
+}
+
+/* ==================== HISTORY.HTML CONTROLS ======================*/
+
+.splitleft, .splitright {
+    width: 48%;
+    position: relative;
+    margin: 0em 1%;
+}
+
+.splitleft {
+    float: left;
+}
+
+.splitright {
+    float: right;
+}
+
+.filledform {
+    display: inline-block;
+    width: 55%;
+    position: relative;
+    border: 2px 2.5%;
+    margin: 3px 0px;
+}
+
+fieldset {
+    border-radius: 8px;
+    border: 2px var(--site-color);
+    border-style: solid none solid none;
+    cursor: default;
+}
+
+legend {
+    border: 1px solid var(--site-color);
+}
+
+table {
+    width: 100%;
+    border-collapse: collapse;
+    border: 1px var(--site-color);
+    position: relative;
+    display: block;
+    overflow: auto;
+}
+
+th, td {
+    border-bottom: 1px solid #ddd;
+    padding: 0.1em;
+}
+
+    th:not(:last-child):not(:first-child),
+    td:not(:last-child):not(:first-child) {
+        border-left: 1px solid var(--site-color);
+        border-right: 1px solid var(--site-color);
+    }
+
+.fillwrap {
+    width: 100%;
+    word-wrap: break-word;
+    word-break: break-all;
+}
+
+#historylist > tbody > tr > td:nth-child(2) {
+    text-align: right;
+}
+
+/* ==================== PLAY CONTROLS ======================*/
+
+#playhandler {
+    display: inline-block;
+    min-width: 24em;
+    width: 100%;
+}
+
+.linebox {
+    display: inline-block;
+    align-items: center;
+    height: 3em;
+    width: 33%;
+    min-width: 12em;
+    text-align: center;
+    padding: 0px;
+    margin: auto;
+}
+
+.rightbox {
+    text-align: right;
+}
+
+.leftbox {
+    text-align: left;
+}
+
+.fillbox {
+    display: inline-block;
+    width: 100%;
+    text-align: left;
+}
+
+.fillline {
+    width: 80%;
+    padding: 0em 10%;
+    margin: 0em;
+}
+
+.playctrlbtn {
+    padding: 0.3em 0.5em;
+    display: inline;
+    margin: 0em;
+}
+
+.playctrlplay {
+    padding: 1em 1em;
+    width: 1em;
+    height: 1em;
+    display: inline-block;
+    margin: 0em;
+    border-radius: 50%;
+}
+
+/* ==================== SLIDER ======================*/
+
+input[type=range] {
+    -webkit-appearance: none;
+    background: transparent;
+}
+
+    input[type=range]:focus {
+        outline: none;
+    }
+
+    input[type=range]::-webkit-slider-thumb {
+        -webkit-appearance: none;
+        border: none;
+        height: 16px;
+        width: 16px;
+        margin-top: -4px;
+        border-radius: 50%;
+        background: white;
+        box-shadow: var(--shadow);
+        cursor: pointer;
+    }
+
+    input[type=range]::-webkit-slider-runnable-track {
+        width: 100%;
+        height: 6px;
+        margin-bottom: 4px;
+        border-radius: 3px;
+        box-shadow: var(--shadow);
+        background: var(--site-color);
+    }
+
+    input[type=range]::-moz-range-thumb {
+        border: none;
+        height: 16px;
+        width: 16px;
+        border-radius: 50%;
+        box-shadow: var(--shadow);
+        cursor: pointer;
+    }
+
+    input[type=range]::-moz-range-track {
+        width: 100%;
+        height: 6px;
+        border-radius: 3px;
+        box-shadow: var(--shadow);
+        background: var(--site-color);
+    }
+
+/* 
*/ diff --git a/TS3AudioBot/favicon.ico b/TS3AudioBot/favicon.ico new file mode 100644 index 00000000..f9d21034 Binary files /dev/null and b/TS3AudioBot/favicon.ico differ diff --git a/TS3AudioBot/packages.config b/TS3AudioBot/packages.config new file mode 100644 index 00000000..87c301a6 --- /dev/null +++ b/TS3AudioBot/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/TS3Client/CommandBinder.cs b/TS3Client/CommandBinder.cs new file mode 100644 index 00000000..76adbb18 --- /dev/null +++ b/TS3Client/CommandBinder.cs @@ -0,0 +1,63 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Reflection; + + public class CommandBinder : CommandParameter + { + private static readonly Dictionary ConstrBuffer = new Dictionary(); + private static ConstructorInfo GetValueCtor(Type t) + { + ConstructorInfo ci; + if (!ConstrBuffer.TryGetValue(t, out ci)) + { + var ctor = typeof(PrimitiveParameter).GetConstructors().Where(c => c.GetParameters().First().ParameterType == t).FirstOrDefault(); + if (ctor == null) + throw new InvalidCastException(); + ci = ctor; + ConstrBuffer.Add(t, ci); + } + return ci; + } + private readonly List buildList = new List(); + public override string QueryString => string.Join(" ", buildList); + + protected CommandBinder() { } + + public static CommandBinder NewBind(string key, IEnumerable parameter) => new CommandBinder().Bind(key, parameter); + public CommandBinder Bind(string key, IEnumerable parameter) + { + var ctor = GetValueCtor(typeof(T)); + var values = parameter.Select(val => (PrimitiveParameter)ctor.Invoke(new object[] { val })); + var result = string.Join("|", values.Select(v => new CommandParameter(key, v).QueryString)); + buildList.Add(result); + return this; + } + + public static CommandBinder NewBind(string key, IEnumerable parameter) => new CommandBinder().Bind(key, parameter); + public CommandBinder Bind(string key, IEnumerable parameter) + { + throw new NotImplementedException(); + //buildList.Add(result); + //return this; + } + } +} diff --git a/TS3Client/CommandDeserializer.cs b/TS3Client/CommandDeserializer.cs new file mode 100644 index 00000000..2b7e47b7 --- /dev/null +++ b/TS3Client/CommandDeserializer.cs @@ -0,0 +1,193 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + using Messages; + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Globalization; + using System.Linq; + using System.Reflection; + using KVEnu = System.Collections.Generic.IEnumerable>; + + static class CommandDeserializer + { + // STATIC LOOKUPS + + /// Maps the name of a notification to the class. + private static readonly Dictionary NotifyLookup; + /// Map of functions to deserialize from query values. + private static readonly Dictionary> ConvertMap; + + static CommandDeserializer() + { + // get all classes deriving from Notification + var derivedNtfy = from asm in AppDomain.CurrentDomain.GetAssemblies() + from type in asm.GetTypes() + where type.IsInterface + where typeof(INotification).IsAssignableFrom(type) + let ntfyAtt = (QueryNotificationAttribute)type.GetCustomAttribute(typeof(QueryNotificationAttribute), false) + where ntfyAtt != null + select new KeyValuePair(ntfyAtt.Name, new NotifyTypeInfo(type, ntfyAtt.NotificationType)); + NotifyLookup = derivedNtfy.ToDictionary(x => x.Key, x => x.Value); + + Util.Init(ref ConvertMap); + ConvertMap.Add(typeof(bool), (v, t) => v != "0"); + ConvertMap.Add(typeof(sbyte), (v, t) => sbyte.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(byte), (v, t) => byte.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(short), (v, t) => short.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(ushort), (v, t) => ushort.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(int), (v, t) => int.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(uint), (v, t) => uint.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(long), (v, t) => long.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(ulong), (v, t) => ulong.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(float), (v, t) => float.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(double), (v, t) => double.Parse(v, CultureInfo.InvariantCulture)); + ConvertMap.Add(typeof(string), (v, t) => TS3String.Unescape(v)); + ConvertMap.Add(typeof(TimeSpan), (v, t) => TimeSpan.FromSeconds(double.Parse(v, CultureInfo.InvariantCulture))); + ConvertMap.Add(typeof(DateTime), (v, t) => PrimitiveParameter.UnixTimeStart.AddSeconds(double.Parse(v, CultureInfo.InvariantCulture))); + } + + // data to error + public static CommandError GenerateErrorStatus(string line) + { + var kvpList = ParseKeyValueLine(line, true); + var errorStatus = new CommandError(); + foreach (var responseParam in kvpList) + { + switch (responseParam.Key.ToUpperInvariant()) + { + case "ID": errorStatus.Id = int.Parse(responseParam.Value, CultureInfo.InvariantCulture); break; + case "MSG": errorStatus.Message = TS3String.Unescape(responseParam.Value); break; + case "FAILED_PERMID": errorStatus.MissingPermissionId = int.Parse(responseParam.Value, CultureInfo.InvariantCulture); break; + case "RETURN_CODE": errorStatus.ReturnCode = TS3String.Unescape(responseParam.Value); break; + } + } + return errorStatus; + } + + // data to notification + public static Tuple, NotificationType> GenerateNotification(string line) + { + string notifyname; + int splitindex = line.IndexOf(' '); + if (splitindex < 0) + notifyname = line.TrimEnd(); + else + notifyname = line.Substring(0, splitindex); + + NotifyTypeInfo targetNotification; + if (NotifyLookup.TryGetValue(notifyname, out targetNotification)) + { + string[] messageList; + if (splitindex < 0) + messageList = new string[0]; + else + messageList = line.Substring(splitindex).TrimStart().Split('|'); + return new Tuple, NotificationType>(messageList.Select(msg => + { + var incomingData = ParseKeyValueLine(msg, false); + var notification = Generator.ActivateNotification(targetNotification.ClassType); + FillQueryMessage(targetNotification.ClassType, notification, incomingData); + return notification; + }), targetNotification.EnumType); + } + else + { + Debug.WriteLine($"No matching notification derivative ({line})"); + return new Tuple, NotificationType>(Enumerable.Empty(), NotificationType.Unknown); + } + } + + public static IEnumerable GenerateResponse(string line, Type answerType) + { + if (answerType == null) + { + if (string.IsNullOrWhiteSpace(line)) + return Enumerable.Empty(); + var messageList = line.Split('|'); + return messageList.Select(msg => new ResponseDictionary(ParseKeyValueLineDict(msg, false))); + } + else + { + if (string.IsNullOrWhiteSpace(line)) + return Enumerable.Empty(); + var messageList = line.Split('|'); + return messageList.Select(msg => + { + var incomingData = ParseKeyValueLine(msg, false); + var response = Generator.ActivateResponse(answerType); + FillQueryMessage(answerType, response, incomingData); + return response; + }); + } + } + + // HELPER + + private static void FillQueryMessage(Type baseType, IQueryMessage qm, KVEnu kvpData) + { + var map = Generator.GetAccessMap(baseType); + foreach (var kvp in kvpData) + { + PropertyInfo prop; + if (!map.TryGetValue(kvp.Key, out prop)) + { + Debug.WriteLine($"Missing Parameter '{kvp.Key}' in '{qm}'"); + continue; + } + object value = DeserializeValue(kvp.Value, prop.PropertyType); + prop.SetValue(qm, value); + } + } + + private static object DeserializeValue(string data, Type dataType) + { + Func converter; + if (ConvertMap.TryGetValue(dataType, out converter)) + return converter(data, dataType); + else if (dataType.IsEnum) + return Enum.ToObject(dataType, Convert.ChangeType(data, dataType.GetEnumUnderlyingType(), CultureInfo.InvariantCulture)); + else + throw new NotSupportedException(); + } + + private static KVEnu ParseKeyValueLine(string line, bool ignoreFirst) + { + if (string.IsNullOrWhiteSpace(line)) + return Enumerable.Empty>(); + IEnumerable splitValues = line.Split(' '); + if (ignoreFirst) splitValues = splitValues.Skip(1); + return from part in splitValues + select part.Split(new[] { '=' }, 2) into keyValuePair + select new KeyValuePair(keyValuePair[0], keyValuePair.Length > 1 ? keyValuePair[1] : string.Empty); + } + private static Dictionary ParseKeyValueLineDict(string line, bool ignoreFirst) + => ParseKeyValueLineDict(ParseKeyValueLine(line, ignoreFirst)); + private static Dictionary ParseKeyValueLineDict(KVEnu data) + => data.ToDictionary(pair => pair.Key, pair => pair.Value); + + private sealed class NotifyTypeInfo + { + public Type ClassType { get; } + public NotificationType EnumType { get; } + + public NotifyTypeInfo(Type classType, NotificationType enumType) { ClassType = classType; EnumType = enumType; } + } + } +} diff --git a/TS3Client/CommandError.cs b/TS3Client/CommandError.cs new file mode 100644 index 00000000..fae81220 --- /dev/null +++ b/TS3Client/CommandError.cs @@ -0,0 +1,34 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + public class CommandError + { + // id + public int Id { get; set; } + // msg + public string Message { get; set; } + // failed_permid + public int MissingPermissionId { get; set; } = -1; + + public string ReturnCode { get; set; } = string.Empty; + + public bool Ok => Id == 0 && Message == "ok"; + + public string ErrorFormat() => $"{Id}: the command failed to execute: {Message} (missing permission:{MissingPermissionId})"; + } +} diff --git a/TS3Client/CommandOption.cs b/TS3Client/CommandOption.cs new file mode 100644 index 00000000..6931fbf0 --- /dev/null +++ b/TS3Client/CommandOption.cs @@ -0,0 +1,32 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + using System; + using System.Linq; + + public class CommandOption + { + public string Value { get; } + + public CommandOption(string name) { Value = string.Concat(" -", name); } + public CommandOption(Enum values) { Value = string.Join(" -", values.GetFlags().Select(enu => Enum.GetName(typeof(Enum), enu))); } + + public static implicit operator CommandOption(string value) => new CommandOption(value); + public static implicit operator CommandOption(Enum value) => new CommandOption(value); + } +} diff --git a/TS3Client/CommandParameter.cs b/TS3Client/CommandParameter.cs new file mode 100644 index 00000000..4868fe19 --- /dev/null +++ b/TS3Client/CommandParameter.cs @@ -0,0 +1,35 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + public class CommandParameter + { + public string Key { get; } + public string Value { get; } + public virtual string QueryString => string.IsNullOrEmpty(Value) ? Key : string.Concat(Key, "=", Value); + + protected CommandParameter() { } + + public CommandParameter(string name, IParameterConverter rawValue) + { + Key = name; + Value = rawValue.QueryValue; + } + + public CommandParameter(string name, PrimitiveParameter value) : this(name, (IParameterConverter)value) { } + } +} diff --git a/TS3Client/ConnectionData.cs b/TS3Client/ConnectionData.cs new file mode 100644 index 00000000..b1671e3b --- /dev/null +++ b/TS3Client/ConnectionData.cs @@ -0,0 +1,29 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + public class ConnectionData + { + public string Hostname { get; set; } // Q F + public ushort Port { get; set; } // Q F + public string UserName { get; set; } // Q F + public string Password { get; set; } // Q F + public string PrivateKey { get; set; } // F + public ulong KeyOffset { get; set; } // F + public ulong LastCheckedKeyOffset { get; set; } // F (optional) + } +} diff --git a/TS3Client/DebugTests.cs b/TS3Client/DebugTests.cs new file mode 100644 index 00000000..9fe9ce1a --- /dev/null +++ b/TS3Client/DebugTests.cs @@ -0,0 +1,34 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Linq; +using System.Runtime.InteropServices; +using System.Text; +using System.Threading.Tasks; +using TS3Client.Full; +using System.Diagnostics; +using System.Net; +using static System.Console; + +namespace TS3Client +{ + static class DebugTests + { + static void Main(string[] args) + { + TS3FullClient fc = new TS3FullClient(EventDispatchType.DoubleThread); + fc.Connect(new ConnectionData + { + UserName = "HAAAX", + Hostname = "splamy.de", + Port = 9987, + PrivateKey = "MG8DAgeAAgEgAiEA76LIMLxiti7JTkl4yeNRPiApiGyIRqF9km3ByalVZd8CIQDGz9jUYZIXgkSsyCYVywl0HTKoP+0Ch8OG+ia4boW0UAIgSY/aeQNjq0ryRiaifd6SMKbG9+KuoN/oXEu/lyr+SNg=", + KeyOffset = 57451630, + LastCheckedKeyOffset = 57451630, + }); + fc.EnterEventLoop(); + ReadLine(); + } + } +} diff --git a/TS3AudioBot/TS3Query/DocumentedEnums.cs b/TS3Client/DocumentedEnums.cs similarity index 98% rename from TS3AudioBot/TS3Query/DocumentedEnums.cs rename to TS3Client/DocumentedEnums.cs index 1b60862c..daf6f36f 100644 --- a/TS3AudioBot/TS3Query/DocumentedEnums.cs +++ b/TS3Client/DocumentedEnums.cs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query +namespace TS3Client { // http://media.teamspeak.com/ts3_literature/TeamSpeak%203%20Server%20Query%20Manual.pdf // public_definitions.h from the ts3 plugin library @@ -30,7 +30,7 @@ public enum HostMessageMode /// Display message in modal dialog and close connection. ModalQuit } - + public enum HostBannerMode { ///Do not adjust. @@ -54,9 +54,9 @@ public enum Codec ///mono, 16bit, 48kHz, optimized for voice OpusVoice, ///stereo, 16bit, 48kHz, optimized for music - OpusMusic, + OpusMusic, } - + public enum CodecEncryptionMode { ///Configure per channel. diff --git a/TS3AudioBot/TS3Query/QueryEventDispatcher.cs b/TS3Client/EventDispatcher.cs similarity index 78% rename from TS3AudioBot/TS3Query/QueryEventDispatcher.cs rename to TS3Client/EventDispatcher.cs index 5b2d77af..2367a5df 100644 --- a/TS3AudioBot/TS3Query/QueryEventDispatcher.cs +++ b/TS3Client/EventDispatcher.cs @@ -14,13 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query +namespace TS3Client { using System; using System.Collections.Concurrent; using System.Threading; - interface IEventDispatcher : IDisposable + public interface IEventDispatcher : IDisposable { EventDispatchType DispatcherType { get; } void Init(Action eventLoop); @@ -31,7 +31,7 @@ interface IEventDispatcher : IDisposable void EnterEventLoop(); } - class CurrentThreadEventDisptcher : IEventDispatcher + internal class CurrentThreadEventDisptcher : IEventDispatcher { private Action eventLoop; public EventDispatchType DispatcherType => EventDispatchType.CurrentThread; @@ -42,21 +42,18 @@ class CurrentThreadEventDisptcher : IEventDispatcher public void Dispose() { } } - class DoubleThreadEventDispatcher : IEventDispatcher + internal class DoubleThreadEventDispatcher : IEventDispatcher { public EventDispatchType DispatcherType => EventDispatchType.DoubleThread; private Thread readQueryThread; - private ConcurrentQueue eventQueue = new ConcurrentQueue(); - private AutoResetEvent eventBlock = new AutoResetEvent(false); + private readonly ConcurrentQueue eventQueue = new ConcurrentQueue(); + private readonly AutoResetEvent eventBlock = new AutoResetEvent(false); private bool run = true; - public DoubleThreadEventDispatcher() { } - public void Init(Action eventLoop) { - readQueryThread = new Thread(eventLoop.Invoke); - readQueryThread.Name = "TS3Query MessageLoop"; + readQueryThread = new Thread(eventLoop.Invoke) { Name = "TS3Query MessageLoop" }; readQueryThread.Start(); } @@ -68,7 +65,7 @@ public void Invoke(Action eventAction) public void EnterEventLoop() { - while (run && eventBlock != null) + while (run) { eventBlock.WaitOne(); while (!eventQueue.IsEmpty) @@ -82,7 +79,9 @@ public void EnterEventLoop() public void Dispose() { - // TODO: replace with thread close util call from webdev branch + run = false; + eventBlock.Set(); + if (readQueryThread != null) { for (int i = 0; i < 100 && readQueryThread.IsAlive; i++) @@ -94,17 +93,11 @@ public void Dispose() } } - run = false; - if (eventBlock != null) - { - eventBlock.Set(); - eventBlock.Dispose(); - eventBlock = null; - } + eventBlock.Dispose(); } } - class NoEventDispatcher : IEventDispatcher + internal class NoEventDispatcher : IEventDispatcher { public EventDispatchType DispatcherType => EventDispatchType.None; public void Init(Action eventLoop) { } @@ -113,7 +106,7 @@ public void Invoke(Action eventAction) { } public void Dispose() { } } - enum EventDispatchType + public enum EventDispatchType { None, CurrentThread, diff --git a/TS3AudioBot/TS3Query/Extensions.cs b/TS3Client/Extensions.cs similarity index 85% rename from TS3AudioBot/TS3Query/Extensions.cs rename to TS3Client/Extensions.cs index ddcb9a15..d9982749 100644 --- a/TS3AudioBot/TS3Query/Extensions.cs +++ b/TS3Client/Extensions.cs @@ -14,13 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query +namespace TS3Client { using System; using System.Collections.Generic; using System.Linq; - static class Extensions + internal static class Extensions { public static string GetQueryString(this Enum valueEnum) { @@ -28,8 +28,8 @@ public static string GetQueryString(this Enum valueEnum) Type enumType = valueEnum.GetType(); var valueField = enumType.GetField(Enum.GetName(enumType, valueEnum)); - var fieldAttributes = valueField.GetCustomAttributes(typeof(QueryStringAttribute), false); - var fieldAttribute = fieldAttributes.Cast().FirstOrDefault(); + var fieldAttributes = valueField.GetCustomAttributes(typeof(TS3SerializableAttribute), false); + var fieldAttribute = fieldAttributes.Cast().FirstOrDefault(); if (fieldAttribute == null) throw new InvalidOperationException("This enum doesn't contain the QueryString attribute"); return fieldAttribute.QueryString; diff --git a/TS3Client/Full/BasePacket.cs b/TS3Client/Full/BasePacket.cs new file mode 100644 index 00000000..778501ee --- /dev/null +++ b/TS3Client/Full/BasePacket.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TS3Client.Full +{ + class BasePacket + { + public PacketType PacketType + { + get { return (PacketType)(PacketTypeFlagged & 0x0F); } + set { PacketTypeFlagged = (byte)((PacketTypeFlagged & 0xF0) | ((byte)value & 0x0F)); } + } + public PacketFlags PacketFlags + { + get { return (PacketFlags)(PacketTypeFlagged & 0xF0); } + set { PacketTypeFlagged = (byte)((PacketTypeFlagged & 0x0F) | ((byte)value & 0xF0)); } + } + public byte PacketTypeFlagged { get; set; } + public ushort PacketId { get; set; } + public int Size => Data.Length; + + public byte[] Raw { get; set; } + public byte[] Header { get; protected set; } + public byte[] Data { get; set; } + + public BasePacket() + { + } + + + public bool FragmentedFlag + { + get { return (PacketFlags.HasFlag(PacketFlags.Fragmented)); } + set + { + if (value) PacketTypeFlagged |= (byte)PacketFlags.Fragmented; + else PacketTypeFlagged &= (byte)~PacketFlags.Fragmented; + } + } + public bool NewProtocolFlag + { + get { return (PacketFlags.HasFlag(PacketFlags.Newprotocol)); } + set + { + if (value) PacketTypeFlagged |= (byte)PacketFlags.Newprotocol; + else PacketTypeFlagged &= (byte)~PacketFlags.Newprotocol; + } + } + public bool CompressedFlag + { + get { return (PacketFlags.HasFlag(PacketFlags.Compressed)); } + set + { + if (value) PacketTypeFlagged |= (byte)PacketFlags.Compressed; + else PacketTypeFlagged &= (byte)~PacketFlags.Compressed; + } + } + public bool UnencryptedFlag + { + get { return (PacketFlags.HasFlag(PacketFlags.Unencrypted)); } + set + { + if (value) PacketTypeFlagged |= (byte)PacketFlags.Unencrypted; + else PacketTypeFlagged &= (byte)~PacketFlags.Unencrypted; + } + } + } +} diff --git a/TS3Client/Full/IdentityData.cs b/TS3Client/Full/IdentityData.cs new file mode 100644 index 00000000..f17c0abd --- /dev/null +++ b/TS3Client/Full/IdentityData.cs @@ -0,0 +1,15 @@ +namespace TS3Client.Full +{ + using Org.BouncyCastle.Math.EC; + using Org.BouncyCastle.Math; + + public class IdentityData + { + public string PublicKeyString { get; set; } + public string PrivateKeyString { get; set; } + public ECPoint PublicKey { get; set; } + public BigInteger PrivateKey { get; set; } + public ulong ValidKeyOffset { get; set; } + public ulong LastCheckedKeyOffset { get; set; } + } +} diff --git a/TS3Client/Full/IncomingPacket.cs b/TS3Client/Full/IncomingPacket.cs new file mode 100644 index 00000000..729d6668 --- /dev/null +++ b/TS3Client/Full/IncomingPacket.cs @@ -0,0 +1,17 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TS3Client.Full +{ + class IncomingPacket : BasePacket + { + public IncomingPacket(byte[] raw) + { + Raw = raw; + Header = new byte[3]; + } + } +} diff --git a/TS3Client/Full/NetUtil.cs b/TS3Client/Full/NetUtil.cs new file mode 100644 index 00000000..450c2e54 --- /dev/null +++ b/TS3Client/Full/NetUtil.cs @@ -0,0 +1,53 @@ +using System; + +namespace TS3Client.Full +{ + using System.Net; + + internal static class NetUtil + { + // Common Network to Host and Host to Network swaps + public static ushort H2N(ushort value) => unchecked((ushort)IPAddress.HostToNetworkOrder((short)value)); + public static ushort N2H(ushort value) => unchecked((ushort)IPAddress.NetworkToHostOrder((short)value)); + public static uint H2N(uint value) => unchecked((uint)IPAddress.HostToNetworkOrder((int)value)); + public static uint N2H(uint value) => unchecked((uint)IPAddress.NetworkToHostOrder((int)value)); + + + public static ushort N2Hushort(byte[] intArr, int inOff) + { + if (!BitConverter.IsLittleEndian) + { + return (ushort)(intArr[inOff] | (intArr[inOff + 1] << 8)); + } + else // IsBigEndian + { + return (ushort)((intArr[inOff] << 8) | intArr[inOff + 1]); + } + } + public static void H2N(ushort value, byte[] outArr, int outOff) + { + if (!BitConverter.IsLittleEndian) + { + outArr[outOff] = (byte)(value & 0xFF); + outArr[outOff + 1] = (byte)((value >> 8) & 0xFF); + } + else // IsBigEndian + { + outArr[outOff] = (byte)((value >> 8) & 0xFF); + outArr[outOff + 1] = (byte)(value & 0xFF); + } + } + + public static int N2Hint(byte[] intArr, int inOff) + { + if (!BitConverter.IsLittleEndian) + { + return intArr[inOff] | (intArr[inOff + 1] << 8) | (intArr[inOff + 2] << 16) | (intArr[inOff + 3] << 24); + } + else // IsBigEndian + { + return (intArr[inOff] << 24) | (intArr[inOff + 1] << 16) | (intArr[inOff + 2] << 8) | intArr[inOff + 3]; + } + } + } +} diff --git a/TS3Client/Full/OutgoingPacket.cs b/TS3Client/Full/OutgoingPacket.cs new file mode 100644 index 00000000..0f590091 --- /dev/null +++ b/TS3Client/Full/OutgoingPacket.cs @@ -0,0 +1,25 @@ +namespace TS3Client.Full +{ + using System; + + class OutgoingPacket : BasePacket + { + public ushort ClientId { get; set; } + + public DateTime LastSendTime { get; set; } = DateTime.MaxValue; + + public OutgoingPacket(byte[] data, PacketType type) + { + Data = data; + PacketType = type; + Header = new byte[5]; + } + + public void BuildHeader() + { + NetUtil.H2N(PacketId, Header, 0); + NetUtil.H2N(ClientId, Header, 2); + Header[4] = PacketTypeFlagged; + } + } +} \ No newline at end of file diff --git a/TS3Client/Full/PacketHandler.cs b/TS3Client/Full/PacketHandler.cs new file mode 100644 index 00000000..07b26b27 --- /dev/null +++ b/TS3Client/Full/PacketHandler.cs @@ -0,0 +1,327 @@ +namespace TS3Client.Full +{ + using System; + using System.Collections.Generic; + using System.Linq; + using System.Net; + using System.Net.Sockets; + using System.Threading; + + internal class PacketHandler + { + /// Greatest allowed packet size, including the complete heder. + private const int MaxPacketSize = 500; + private const int HeaderSize = 13; + + private const int PacketBufferSize = 50; + private static readonly TimeSpan PacketTimeout = TimeSpan.FromSeconds(1); + + private readonly ushort[] packetCounter; + private readonly LinkedList sendQueue; + private readonly RingQueue receiveQueue; + private readonly Thread resendThread; + private readonly object sendLoopMonitor = new object(); + private readonly TS3Crypt ts3Crypt; + private readonly UdpClient udpClient; + + public ushort ClientId { get; set; } + + public PacketHandler(TS3Crypt ts3Crypt, UdpClient udpClient) + { + sendQueue = new LinkedList(); + receiveQueue = new RingQueue(PacketBufferSize); + resendThread = new Thread(ResendLoop); + packetCounter = new ushort[9]; + this.ts3Crypt = ts3Crypt; + this.udpClient = udpClient; + } + + public void Start() + { + // TODO: check run! i think we need to recreate the thread.... + if (!resendThread.IsAlive) + resendThread.Start(); + } + + public void AddOutgoingPacket(byte[] packet, PacketType packetType) + { + var addFlags = PacketFlags.None; + if (NeedsSplitting(packet.Length)) + { + packet = QuickLZ.compress(packet, 3); + addFlags |= PacketFlags.Compressed; + + if (NeedsSplitting(packet.Length)) + { + foreach (var splitPacket in BuildSplitList(packet, packetType)) + AddOutgoingPacket(splitPacket, addFlags); + return; + } + } + AddOutgoingPacket(new OutgoingPacket(packet, packetType), addFlags); + } + + private void AddOutgoingPacket(OutgoingPacket packet, PacketFlags flags = PacketFlags.None) + { + if (packet.PacketType == PacketType.Init1) + { + packet.PacketFlags |= flags | PacketFlags.Unencrypted; + packet.PacketId = 101; + packet.ClientId = 0; + } + else + { + if (packet.PacketType == PacketType.Pong) + packet.PacketFlags |= flags | PacketFlags.Unencrypted; + else if (packet.PacketType == PacketType.Ack) + packet.PacketFlags |= flags; + else + packet.PacketFlags |= flags | PacketFlags.Newprotocol; + packet.PacketId = GetPacketCounter(packet.PacketType); + if (ts3Crypt.CryptoInitComplete) + IncPacketCounter(packet.PacketType); + packet.ClientId = ClientId; + } + + if (!ts3Crypt.Encrypt(packet)) + throw new Exception(); // TODO + + if (packet.PacketType == PacketType.Command) + lock (sendLoopMonitor) + sendQueue.AddLast(packet); + + SendRaw(packet); + } + + private ushort GetPacketCounter(PacketType packetType) => packetCounter[(int)packetType]; + private void IncPacketCounter(PacketType packetType) => packetCounter[(int)packetType]++; + + public void CryptoInitDone() + { + if (!ts3Crypt.CryptoInitComplete) + throw new Exception("No it's not >:("); + IncPacketCounter(PacketType.Command); + } + + private static IEnumerable BuildSplitList(byte[] rawData, PacketType packetType) + { + int pos = 0; + bool first = true; + bool last; + + const int maxContent = MaxPacketSize - HeaderSize; + do + { + int blockSize = Math.Min(maxContent, rawData.Length - pos); + if (blockSize <= 0) break; + + var tmpBuffer = new byte[blockSize]; + Array.Copy(rawData, pos, tmpBuffer, 0, blockSize); + var packet = new OutgoingPacket(tmpBuffer, packetType); + + last = pos + blockSize == rawData.Length; + if (first ^ last) + packet.FragmentedFlag = true; + if (first) + first = false; + + yield return packet; + pos += blockSize; + + } while (!last); + } + + private static bool NeedsSplitting(int dataSize) => dataSize + HeaderSize > MaxPacketSize; + + public IncomingPacket FetchPacket() + { + while (true) + { + var dummy = new IPEndPoint(IPAddress.Any, 0); + byte[] buffer = udpClient.Receive(ref dummy); + if (/*dummy.Address.Equals(remoteIpAddress) &&*/ dummy.Port != 9987) // todo + continue; + + var packet = ts3Crypt.Decrypt(buffer); + if (packet == null) + continue; + + bool passToReturn = true; + switch (packet.PacketType) + { + case PacketType.Readable: break; + case PacketType.Voice: break; + case PacketType.Command: passToReturn = ReceiveCommand(packet); break; + case PacketType.CommandLow: break; + case PacketType.Ping: passToReturn = ReceivePing(packet); break; + case PacketType.Pong: break; + case PacketType.Ack: passToReturn = ReceiveAck(packet); break; + case PacketType.Type7Closeconnection: break; + case PacketType.Init1: break; + default: + throw new ArgumentOutOfRangeException(); + } + + if (passToReturn) + return packet; + } + } + + #region Packet checking + // These methods are for low level packet processing which the + // rather high level TS3FullClient should not worry about. + + private bool ReceiveCommand(IncomingPacket packet) + { + SendAck(packet.PacketId); + if (!receiveQueue.IsSet(packet.PacketId)) + { + receiveQueue.Set(packet, packet.PacketId); + int take = 0; + int takeLen = 0; + bool hasStart = false; + bool hasEnd = false; + for (int i = 0; i < receiveQueue.Count; i++) + { + IncomingPacket peekPacket; + if (receiveQueue.TryPeek(receiveQueue.StartIndex, out peekPacket)) + { + take++; + takeLen += peekPacket.Size; + if (peekPacket.FragmentedFlag) + { + if (!hasStart) { hasStart = true; } + else if (!hasEnd) { hasEnd = true; break; } + } + else + { + if (!hasStart) { hasStart = true; hasEnd = true; break; } + } + } + else + break; + } + if (hasStart && hasEnd) + { + IncomingPacket preFinalPacket = null; + if (take == 1) + { + // MERGE (skip with only 1) + if (!receiveQueue.TryDequeue(out preFinalPacket)) + throw new InvalidOperationException(); + // DECOMPRESS + if (preFinalPacket.CompressedFlag) + packet.Data = QuickLZ.decompress(preFinalPacket.Data); + return true; + } + else // take > 1 + { + // MERGE + var preFinalArray = new byte[takeLen]; + int curCopyPos = 0; + bool firstSet = false; + bool isCompressed = false; + for (int i = 0; i < take; i++) + { + if (!receiveQueue.TryDequeue(out preFinalPacket)) + throw new InvalidOperationException(); + if(!firstSet) + { + isCompressed = preFinalPacket.CompressedFlag; + firstSet = true; + } + Array.Copy(preFinalPacket.Data, 0, preFinalArray, curCopyPos, preFinalPacket.Size); + curCopyPos += preFinalPacket.Size; + } + // DECOMPRESS + if (isCompressed) + packet.Data = QuickLZ.decompress(preFinalArray); + else + packet.Data = preFinalArray; + } + + return true; + } + else + return false; + } + return false; + } + + private void SendAck(ushort ackId) + { + byte[] ackData = new byte[2]; + NetUtil.H2N(ackId, ackData, 0); + AddOutgoingPacket(ackData, PacketType.Ack); + } + + private bool ReceiveAck(IncomingPacket packet) + { + if (packet.Data.Length < 2) + return false; + ushort packetId = NetUtil.N2Hushort(packet.Data, 0); + + lock (sendLoopMonitor) + for (var node = sendQueue.First; node != null; node = node.Next) + if (node.Value.PacketId == packetId) + sendQueue.Remove(node); + return true; + } + + private bool ReceivePing(IncomingPacket packet) + { + byte[] pongData = new byte[2]; + NetUtil.H2N(packet.PacketId, pongData, 0); + AddOutgoingPacket(pongData, PacketType.Pong); + return true; + } + + #endregion + + /// + /// ResendLoop will regularly check if a packet has be acknowleged and trys to send it again + /// if the timeout for a packet ran out. + /// + private void ResendLoop() + { + while (true) + { + TimeSpan sleepSpan = PacketTimeout; + + lock (sendLoopMonitor) + { + if (!sendQueue.Any()) + Monitor.Wait(sendLoopMonitor, sleepSpan); + + if (!sendQueue.Any()) + continue; + + foreach (var outgoingPacket in sendQueue) + { + var nextTest = (outgoingPacket.LastSendTime - DateTime.UtcNow) + PacketTimeout; + if (nextTest < TimeSpan.Zero) + SendRaw(outgoingPacket); + else if (nextTest < sleepSpan) + sleepSpan = nextTest; + } + + Thread.Sleep(sleepSpan); + } + } + } + + private void SendRaw(OutgoingPacket packet) + { + packet.LastSendTime = DateTime.UtcNow; + udpClient.Send(packet.Raw, packet.Raw.Length); + } + + public void Reset() + { + ClientId = 0; + sendQueue.Clear(); + receiveQueue.Clear(); + Array.Clear(packetCounter, 0, packetCounter.Length); + } + } +} diff --git a/TS3Client/Full/PacketType.cs b/TS3Client/Full/PacketType.cs new file mode 100644 index 00000000..6577e005 --- /dev/null +++ b/TS3Client/Full/PacketType.cs @@ -0,0 +1,27 @@ +namespace TS3Client.Full +{ + using System; + + public enum PacketType : byte + { + Readable = 0x0, + Voice = 0x1, + Command = 0x2, + CommandLow = 0x3, + Ping = 0x4, + Pong = 0x5, + Ack = 0x6, + Type7Closeconnection = 0x7, + Init1 = 0x8, + } + + [Flags] + public enum PacketFlags : byte + { + None = 0x0, + Fragmented = 0x10, + Newprotocol = 0x20, + Compressed = 0x40, + Unencrypted = 0x80, + } +} diff --git a/TS3Client/Full/QuickLZ.cs b/TS3Client/Full/QuickLZ.cs new file mode 100644 index 00000000..15136e9a --- /dev/null +++ b/TS3Client/Full/QuickLZ.cs @@ -0,0 +1,481 @@ +// QuickLZ data compression library +// Copyright (C) 2006-2011 Lasse Mikkel Reinhold +// lar@quicklz.com +// +// QuickLZ can be used for free under the GPL 1, 2 or 3 license (where anything +// released into public must be open source) or under a commercial license if such +// has been acquired (see http://www.quicklz.com/order.html). The commercial license +// does not cover derived or ported versions created by third parties under GPL. +// +// Only a subset of the C library has been ported, namely level 1 and 3 not in +// streaming mode. +// +// Version: 1.5.0 final + +using System; + +#pragma warning disable CS0675 +static class QuickLZ +{ + public const int QLZ_VERSION_MAJOR = 1; + public const int QLZ_VERSION_MINOR = 5; + public const int QLZ_VERSION_REVISION = 0; + + // Streaming mode not supported + public const int QLZ_STREAMING_BUFFER = 0; + + // Bounds checking not supported Use try...catch instead + public const int QLZ_MEMORY_SAFE = 0; + + // Decrease QLZ_POINTERS_3 to increase level 3 compression speed. Do not edit any other values! + private const int HASH_VALUES = 4096; + private const int MINOFFSET = 2; + private const int UNCONDITIONAL_MATCHLEN = 6; + private const int UNCOMPRESSED_END = 4; + private const int CWORD_LEN = 4; + private const int DEFAULT_HEADERLEN = 9; + private const int QLZ_POINTERS_1 = 1; + private const int QLZ_POINTERS_3 = 16; + + private static int headerLen(byte[] source) + { + return ((source[0] & 2) == 2) ? 9 : 3; + } + + public static int sizeDecompressed(byte[] source) + { + if (headerLen(source) == 9) + return source[5] | (source[6] << 8) | (source[7] << 16) | (source[8] << 24); + else + return source[2]; + } + + public static int sizeCompressed(byte[] source) + { + if (headerLen(source) == 9) + return source[1] | (source[2] << 8) | (source[3] << 16) | (source[4] << 24); + else + return source[1]; + } + + private static void write_header(byte[] dst, int level, bool compressible, int size_compressed, int size_decompressed) + { + dst[0] = (byte)(2 | (compressible ? 1 : 0)); + dst[0] |= (byte)(level << 2); + dst[0] |= (1 << 6); + dst[0] |= (0 << 4); + fast_write(dst, 1, size_decompressed, 4); + fast_write(dst, 5, size_compressed, 4); + } + + public static byte[] compress(byte[] source, int level) + { + int src = 0; + int dst = DEFAULT_HEADERLEN + CWORD_LEN; + uint cword_val = 0x80000000; + int cword_ptr = DEFAULT_HEADERLEN; + byte[] destination = new byte[source.Length + 400]; + int[,] hashtable; + int[] cachetable = new int[HASH_VALUES]; + byte[] hash_counter = new byte[HASH_VALUES]; + byte[] d2; + int fetch = 0; + int last_matchstart = (source.Length - UNCONDITIONAL_MATCHLEN - UNCOMPRESSED_END - 1); + int lits = 0; + + if (level != 1 && level != 3) + throw new ArgumentException("C# version only supports level 1 and 3"); + + if (level == 1) + hashtable = new int[HASH_VALUES, QLZ_POINTERS_1]; + else + hashtable = new int[HASH_VALUES, QLZ_POINTERS_3]; + + if (source.Length == 0) + return new byte[0]; + + if (src <= last_matchstart) + fetch = source[src] | (source[src + 1] << 8) | (source[src + 2] << 16); + + while (src <= last_matchstart) + { + if ((cword_val & 1) == 1) + { + if (src > source.Length >> 1 && dst > src - (src >> 5)) + { + d2 = new byte[source.Length + DEFAULT_HEADERLEN]; + write_header(d2, level, false, source.Length, source.Length + DEFAULT_HEADERLEN); + System.Array.Copy(source, 0, d2, DEFAULT_HEADERLEN, source.Length); + return d2; + } + + fast_write(destination, cword_ptr, (int)((cword_val >> 1) | 0x80000000), 4); + cword_ptr = dst; + dst += CWORD_LEN; + cword_val = 0x80000000; + } + + if (level == 1) + { + int hash = ((fetch >> 12) ^ fetch) & (HASH_VALUES - 1); + int o = hashtable[hash, 0]; + int cache = cachetable[hash] ^ fetch; + cachetable[hash] = fetch; + hashtable[hash, 0] = src; + + if (cache == 0 && hash_counter[hash] != 0 && (src - o > MINOFFSET || (src == o + 1 && lits >= 3 && src > 3 && source[src] == source[src - 3] && source[src] == source[src - 2] && source[src] == source[src - 1] && source[src] == source[src + 1] && source[src] == source[src + 2]))) + { + cword_val = ((cword_val >> 1) | 0x80000000); + if (source[o + 3] != source[src + 3]) + { + int f = 3 - 2 | (hash << 4); + destination[dst + 0] = (byte)(f >> 0 * 8); + destination[dst + 1] = (byte)(f >> 1 * 8); + src += 3; + dst += 2; + } + else + { + int old_src = src; + int remaining = ((source.Length - UNCOMPRESSED_END - src + 1 - 1) > 255 ? 255 : (source.Length - UNCOMPRESSED_END - src + 1 - 1)); + + src += 4; + if (source[o + src - old_src] == source[src]) + { + src++; + if (source[o + src - old_src] == source[src]) + { + src++; + while (source[o + (src - old_src)] == source[src] && (src - old_src) < remaining) + src++; + } + } + + int matchlen = src - old_src; + + hash <<= 4; + if (matchlen < 18) + { + int f = (hash | (matchlen - 2)); + destination[dst + 0] = (byte)(f >> 0 * 8); + destination[dst + 1] = (byte)(f >> 1 * 8); + dst += 2; + } + else + { + fast_write(destination, dst, hash | (matchlen << 16), 3); + dst += 3; + } + } + fetch = source[src] | (source[src + 1] << 8) | (source[src + 2] << 16); + lits = 0; + } + else + { + lits++; + hash_counter[hash] = 1; + destination[dst] = source[src]; + cword_val = (cword_val >> 1); + src++; + dst++; + fetch = ((fetch >> 8) & 0xffff) | (source[src + 2] << 16); + } + + } + else + { + fetch = source[src] | (source[src + 1] << 8) | (source[src + 2] << 16); + + int o, offset2; + int matchlen, k, m, best_k = 0; + byte c; + int remaining = ((source.Length - UNCOMPRESSED_END - src + 1 - 1) > 255 ? 255 : (source.Length - UNCOMPRESSED_END - src + 1 - 1)); + int hash = ((fetch >> 12) ^ fetch) & (HASH_VALUES - 1); + + c = hash_counter[hash]; + matchlen = 0; + offset2 = 0; + for (k = 0; k < QLZ_POINTERS_3 && c > k; k++) + { + o = hashtable[hash, k]; + if ((byte)fetch == source[o] && (byte)(fetch >> 8) == source[o + 1] && (byte)(fetch >> 16) == source[o + 2] && o < src - MINOFFSET) + { + m = 3; + while (source[o + m] == source[src + m] && m < remaining) + m++; + if ((m > matchlen) || (m == matchlen && o > offset2)) + { + offset2 = o; + matchlen = m; + best_k = k; + } + } + } + o = offset2; + hashtable[hash, c & (QLZ_POINTERS_3 - 1)] = src; + c++; + hash_counter[hash] = c; + + if (matchlen >= 3 && src - o < 131071) + { + int offset = src - o; + + for (int u = 1; u < matchlen; u++) + { + fetch = source[src + u] | (source[src + u + 1] << 8) | (source[src + u + 2] << 16); + hash = ((fetch >> 12) ^ fetch) & (HASH_VALUES - 1); + c = hash_counter[hash]++; + hashtable[hash, c & (QLZ_POINTERS_3 - 1)] = src + u; + } + + src += matchlen; + cword_val = ((cword_val >> 1) | 0x80000000); + + if (matchlen == 3 && offset <= 63) + { + fast_write(destination, dst, offset << 2, 1); + dst++; + } + else if (matchlen == 3 && offset <= 16383) + { + fast_write(destination, dst, (offset << 2) | 1, 2); + dst += 2; + } + else if (matchlen <= 18 && offset <= 1023) + { + fast_write(destination, dst, ((matchlen - 3) << 2) | (offset << 6) | 2, 2); + dst += 2; + } + else if (matchlen <= 33) + { + fast_write(destination, dst, ((matchlen - 2) << 2) | (offset << 7) | 3, 3); + dst += 3; + } + else + { + fast_write(destination, dst, ((matchlen - 3) << 7) | (offset << 15) | 3, 4); + dst += 4; + } + lits = 0; + } + else + { + destination[dst] = source[src]; + cword_val = (cword_val >> 1); + src++; + dst++; + } + } + } + while (src <= source.Length - 1) + { + if ((cword_val & 1) == 1) + { + fast_write(destination, cword_ptr, (int)((cword_val >> 1) | 0x80000000), 4); + cword_ptr = dst; + dst += CWORD_LEN; + cword_val = 0x80000000; + } + + destination[dst] = source[src]; + src++; + dst++; + cword_val = (cword_val >> 1); + } + while ((cword_val & 1) != 1) + { + cword_val = (cword_val >> 1); + } + fast_write(destination, cword_ptr, (int)((cword_val >> 1) | 0x80000000), CWORD_LEN); + write_header(destination, level, true, source.Length, dst); + d2 = new byte[dst]; + System.Array.Copy(destination, d2, dst); + return d2; + } + + + private static void fast_write(byte[] a, int i, int value, int numbytes) + { + for (int j = 0; j < numbytes; j++) + a[i + j] = (byte)(value >> (j * 8)); + } + + public static byte[] decompress(byte[] source) + { + int level; + int size = sizeDecompressed(source); + int src = headerLen(source); + int dst = 0; + uint cword_val = 1; + byte[] destination = new byte[size]; + int[] hashtable = new int[4096]; + byte[] hash_counter = new byte[4096]; + int last_matchstart = size - UNCONDITIONAL_MATCHLEN - UNCOMPRESSED_END - 1; + int last_hashed = -1; + int hash; + uint fetch = 0; + + level = (source[0] >> 2) & 0x3; + + if (level != 1 && level != 3) + throw new ArgumentException("C# version only supports level 1 and 3"); + + if ((source[0] & 1) != 1) + { + byte[] d2 = new byte[size]; + System.Array.Copy(source, headerLen(source), d2, 0, size); + return d2; + } + + for (;;) + { + if (cword_val == 1) + { + cword_val = (uint)(source[src] | (source[src + 1] << 8) | (source[src + 2] << 16) | (source[src + 3] << 24)); + src += 4; + if (dst <= last_matchstart) + { + if (level == 1) + fetch = (uint)(source[src] | (source[src + 1] << 8) | (source[src + 2] << 16)); + else + fetch = (uint)(source[src] | (source[src + 1] << 8) | (source[src + 2] << 16) | (source[src + 3] << 24)); + } + } + + if ((cword_val & 1) == 1) + { + uint matchlen; + uint offset2; + + cword_val = cword_val >> 1; + + if (level == 1) + { + hash = ((int)fetch >> 4) & 0xfff; + offset2 = (uint)hashtable[hash]; + + if ((fetch & 0xf) != 0) + { + matchlen = (fetch & 0xf) + 2; + src += 2; + } + else + { + matchlen = source[src + 2]; + src += 3; + } + } + else + { + uint offset; + if ((fetch & 3) == 0) + { + offset = (fetch & 0xff) >> 2; + matchlen = 3; + src++; + } + else if ((fetch & 2) == 0) + { + offset = (fetch & 0xffff) >> 2; + matchlen = 3; + src += 2; + } + else if ((fetch & 1) == 0) + { + offset = (fetch & 0xffff) >> 6; + matchlen = ((fetch >> 2) & 15) + 3; + src += 2; + } + else if ((fetch & 127) != 3) + { + offset = (fetch >> 7) & 0x1ffff; + matchlen = ((fetch >> 2) & 0x1f) + 2; + src += 3; + } + else + { + offset = (fetch >> 15); + matchlen = ((fetch >> 7) & 255) + 3; + src += 4; + } + offset2 = (uint)(dst - offset); + } + + destination[dst + 0] = destination[offset2 + 0]; + destination[dst + 1] = destination[offset2 + 1]; + destination[dst + 2] = destination[offset2 + 2]; + + for (int i = 3; i < matchlen; i += 1) + { + destination[dst + i] = destination[offset2 + i]; + } + + dst += (int)matchlen; + + if (level == 1) + { + fetch = (uint)(destination[last_hashed + 1] | (destination[last_hashed + 2] << 8) | (destination[last_hashed + 3] << 16)); + while (last_hashed < dst - matchlen) + { + last_hashed++; + hash = (int)(((fetch >> 12) ^ fetch) & (HASH_VALUES - 1)); + hashtable[hash] = last_hashed; + hash_counter[hash] = 1; + fetch = (uint)(fetch >> 8 & 0xffff | destination[last_hashed + 3] << 16); + } + fetch = (uint)(source[src] | (source[src + 1] << 8) | (source[src + 2] << 16)); + } + else + { + fetch = (uint)(source[src] | (source[src + 1] << 8) | (source[src + 2] << 16) | (source[src + 3] << 24)); + } + last_hashed = dst - 1; + } + else + { + if (dst <= last_matchstart) + { + destination[dst] = source[src]; + dst += 1; + src += 1; + cword_val = cword_val >> 1; + + if (level == 1) + { + while (last_hashed < dst - 3) + { + last_hashed++; + int fetch2 = destination[last_hashed] | (destination[last_hashed + 1] << 8) | (destination[last_hashed + 2] << 16); + hash = ((fetch2 >> 12) ^ fetch2) & (HASH_VALUES - 1); + hashtable[hash] = last_hashed; + hash_counter[hash] = 1; + } + fetch = (uint)(fetch >> 8 & 0xffff | source[src + 2] << 16); + } + else + { + fetch = (uint)(fetch >> 8 & 0xffff | source[src + 2] << 16 | source[src + 3] << 24); + } + } + else + { + while (dst <= size - 1) + { + if (cword_val == 1) + { + src += CWORD_LEN; + cword_val = 0x80000000; + } + + destination[dst] = source[src]; + dst++; + src++; + cword_val = cword_val >> 1; + } + return destination; + } + } + } + } +} +#pragma warning restore CS0675 \ No newline at end of file diff --git a/TS3Client/Full/RingQueue.cs b/TS3Client/Full/RingQueue.cs new file mode 100644 index 00000000..a96f3730 --- /dev/null +++ b/TS3Client/Full/RingQueue.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TS3Client.Full +{ + internal class RingQueue + { + private int currentStart; + private T[] ringBuffer; + private bool[] ringDoneState; + + public int StartIndex { get; private set; } + public int EndIndex => StartIndex + Count; + public int Count { get; private set; } + + public RingQueue(int bufferSize) + { + ringBuffer = new T[bufferSize]; + ringDoneState = new bool[bufferSize]; + Clear(); + } + + public bool Fits(int index) => index < StartIndex + ringBuffer.Length; + + public void Set(T data, int index) + { + if (!Fits(index)) + throw new ArgumentOutOfRangeException(nameof(index), "Buffer is not large enough for this object."); + if (IsSet(index)) + throw new ArgumentOutOfRangeException(nameof(index), "Object already set."); + + int localIndex = IndexToLocal(index); + ringBuffer[localIndex] = data; + ringDoneState[localIndex] = true; + Count++; + } + + public bool TryDequeue(out T obj) + { + if (!TryPeek(StartIndex, out obj)) return false; + + ringDoneState[currentStart] = false; + + StartIndex++; + Count--; + currentStart = (currentStart + 1) % ringBuffer.Length; + return true; + } + + public bool TryPeek(int index, out T obj) + { + int localIndex = IndexToLocal(index); + if (ringDoneState[localIndex] != true) { obj = default(T); return false; } + else { obj = ringBuffer[localIndex]; return true; } + } + + public bool IsSet(int index) + { + if (index < StartIndex) return true; + if (index >= EndIndex) return false; + return ringDoneState[IndexToLocal(index)]; + } + + private int IndexToLocal(int index) => (currentStart + index - StartIndex) % ringBuffer.Length; + + public void Clear() + { + currentStart = 0; + StartIndex = 0; + Count = 0; + } + } +} diff --git a/TS3Client/Full/TS3Crypt.cs b/TS3Client/Full/TS3Crypt.cs new file mode 100644 index 00000000..303957df --- /dev/null +++ b/TS3Client/Full/TS3Crypt.cs @@ -0,0 +1,534 @@ +namespace TS3Client.Full +{ + using Org.BouncyCastle.Asn1; + using Org.BouncyCastle.Asn1.X9; + using Org.BouncyCastle.Crypto; + using Org.BouncyCastle.Crypto.Digests; + using Org.BouncyCastle.Crypto.Engines; + using Org.BouncyCastle.Crypto.Generators; + using Org.BouncyCastle.Crypto.Modes; + using Org.BouncyCastle.Crypto.Parameters; + using Org.BouncyCastle.Math; + using Org.BouncyCastle.Math.EC; + using Org.BouncyCastle.Security; + using System; + using System.Linq; + using System.Text; + + sealed class TS3Crypt + { + private const string DummyKeyAndNonceString = "c:\\windows\\system\\firewall32.cpl"; + private static readonly byte[] DummyKey = Encoding.ASCII.GetBytes(DummyKeyAndNonceString.Substring(0, 16)); + private static readonly byte[] DummyIv = Encoding.ASCII.GetBytes(DummyKeyAndNonceString.Substring(16, 16)); + private static readonly Tuple DummyKeyAndNonceTuple = new Tuple(DummyKey, DummyIv); + private static readonly byte[] TS3InitMac = Encoding.ASCII.GetBytes("TS3INIT1"); + private static readonly byte[] Initversion = { 0x06, 0x3b, 0xec, 0xe9 }; + private readonly EaxBlockCipher eaxCipher = new EaxBlockCipher(new AesEngine()); + + private const int MacLen = 8; + private const int OutHeaderLen = 5; + private const int InHeaderLen = 3; + private const int PacketTypeKinds = 9; + + public IdentityData Identity { get; private set; } + + public bool CryptoInitComplete { get; private set; } + private readonly byte[] ivStruct = new byte[20]; + private readonly byte[] fakeSignature = new byte[MacLen]; + private readonly Tuple[] cachedKeyNonces = new Tuple[PacketTypeKinds * 2]; + + public TS3Crypt() + { + Reset(); + } + + public void Reset() + { + CryptoInitComplete = false; + Array.Clear(ivStruct, 0, ivStruct.Length); + Array.Clear(fakeSignature, 0, fakeSignature.Length); + Array.Clear(cachedKeyNonces, 0, cachedKeyNonces.Length); + Identity = null; + } + + #region KEY IMPORT/EXPROT + + /// This methods loads the public and private key of our own identity. + /// The key stored in base64, encoded like the libtomcrypt export method (of a private key). + /// A number which determines the security level of an identity. + /// The last brute forced number. + public void LoadIdentity(string key, ulong keyOffset, ulong lastCheckedKeyOffset = 0) + { + // Note: libtomcrypt stores the private AND public key when exporting a private key + // This makes importing very convenient :) + byte[] asnByteArray = Convert.FromBase64String(key); + var pubPrivKey = ImportPrivateKey(asnByteArray); + LoadIdentity(pubPrivKey, keyOffset, lastCheckedKeyOffset); + } + + private void LoadIdentity(Tuple pubPrivKey, ulong keyOffset, ulong lastCheckedKeyOffset) + { + Identity = new IdentityData + { + PublicKey = pubPrivKey.Item1, + PrivateKey = pubPrivKey.Item2, + PublicKeyString = ExportPublicKey(pubPrivKey.Item1), + PrivateKeyString = ExportPrivateKey(pubPrivKey), + ValidKeyOffset = keyOffset, + LastCheckedKeyOffset = lastCheckedKeyOffset < keyOffset ? keyOffset : lastCheckedKeyOffset, + }; + } + + private static readonly ECKeyGenerationParameters KeyGenParams = new ECKeyGenerationParameters(X9ObjectIdentifiers.Prime256v1, new SecureRandom()); + + private static ECPoint ImportPublicKey(byte[] asnByteArray) + { + var asnKeyData = (DerSequence)Asn1Object.FromByteArray(asnByteArray); + var x = (asnKeyData[2] as DerInteger).Value; + var y = (asnKeyData[3] as DerInteger).Value; + + var ecPoint = KeyGenParams.DomainParameters.Curve.CreatePoint(x, y); + return ecPoint; + } + + private static Tuple ImportPrivateKey(byte[] asnByteArray) + { + var asnKeyData = (DerSequence)Asn1Object.FromByteArray(asnByteArray); + var x = (asnKeyData[2] as DerInteger).Value; + var y = (asnKeyData[3] as DerInteger).Value; + var bigi = (asnKeyData[4] as DerInteger).Value; + + var ecPoint = KeyGenParams.DomainParameters.Curve.CreatePoint(x, y); + return new Tuple(ecPoint, bigi); + } + + private static string ExportPublicKey(ECPoint publicKeyPoint) + { + var dataArray = new DerSequence( + new DerBitString(new byte[] { 0 }, 7), + new DerInteger(32), + new DerInteger(publicKeyPoint.AffineXCoord.ToBigInteger()), + new DerInteger(publicKeyPoint.AffineYCoord.ToBigInteger())).GetDerEncoded(); + return Convert.ToBase64String(dataArray); + } + + private static string ExportPrivateKey(Tuple pubPrivKey) + { + var dataArray = new DerSequence( + new DerBitString(new byte[] { 128 }, 7), + new DerInteger(32), + new DerInteger(pubPrivKey.Item1.AffineXCoord.ToBigInteger()), + new DerInteger(pubPrivKey.Item1.AffineYCoord.ToBigInteger()), + new DerInteger(pubPrivKey.Item2)).GetDerEncoded(); + return Convert.ToBase64String(dataArray); + } + + #endregion + + #region TS3INIT1 / CRYPTO INIT + + /// Calculates and initializes all required variables for the secure communication. + /// The alpha key from clientinit encoded in base64. + /// The beta key from clientinit encoded in base64. + /// The omega key from clientinit encoded in base64. + public void CryptoInit(string alpha, string beta, string omega) + { + if (Identity == null) + throw new InvalidOperationException($"No identity has been imported or created. Use the {nameof(LoadIdentity)} or {nameof(GenerateNewIdentity)} method before."); + + var alphaBytes = Convert.FromBase64String(alpha); + var betaBytes = Convert.FromBase64String(beta); + var omegaBytes = Convert.FromBase64String(omega); + var serverPublicKey = ImportPublicKey(omegaBytes); + + byte[] sharedKey = GetSharedSecret(serverPublicKey); + SetSharedSecret(alphaBytes, betaBytes, sharedKey); + + CryptoInitComplete = true; + } + + /// Calculates a shared secred with ECDH from the client private and server public key. + /// The public key of the server. + /// Returns a 32 byte shared secret. + private byte[] GetSharedSecret(ECPoint publicKeyPoint) + { + ECPoint p = publicKeyPoint.Multiply(Identity.PrivateKey).Normalize(); + byte[] keyArr = p.AffineXCoord.ToBigInteger().ToByteArray(); + var sharedData = new byte[32]; + Array.Copy(keyArr, keyArr.Length - 32, sharedData, 0, 32); + return sharedData; + } + + /// Initializes all required variables for the secure communication. + /// The alpha key from clientinit. + /// The beta key from clientinit. + /// The omega key from clientinit. + private void SetSharedSecret(byte[] alpha, byte[] beta, byte[] sharedKey) + { + // prepares the ivstruct consisting of 2 random byte chains of 10 bytes which each both clients agreed on + Array.Copy(alpha, 0, ivStruct, 0, 10); + Array.Copy(beta, 0, ivStruct, 10, 10); + + // applying hashes to get the required values for ts3 + var buffer = Hash1It(sharedKey); + XorBinary(ivStruct, buffer, 20, ivStruct); + + // creating a dummy signature which will be used on packets which dont use a real encryption signature (like plain voice) + buffer = Hash1It(ivStruct, 0, 20); + Array.Copy(buffer, 0, fakeSignature, 0, 8); + } + + public byte[] ProcessInit1(byte[] data) + { + const int versionLen = 4; + const int initTypeLen = 1; + + if (data == null) + { + var sendData = new byte[versionLen + initTypeLen + 4 + 4 + 8]; + Array.Copy(Initversion, 0, sendData, 0, versionLen); // initVersion + sendData[versionLen] = 0x00; // initType + for (int i = 0; i < 8; i++) sendData[i + versionLen + initTypeLen] = 0x42; // should be 4byte timestamp + 4byte random + return sendData; + } + + if (data.Length < initTypeLen) return null; + int type = data[0]; + if (type == 1) + { + var sendData = new byte[versionLen + initTypeLen + 16 + 4]; + Array.Copy(Initversion, 0, sendData, 0, versionLen); // initVersion + sendData[versionLen] = 0x02; // initType + Array.Copy(data, 1, sendData, versionLen + initTypeLen, 20); + return sendData; + } + else if (type == 3) + { + string initAdd = TS3Command.BuildToString("clientinitiv", + new[] { + new CommandParameter("alpha", "AAAAAAAAAAAAAA=="), + new CommandParameter("omega", Identity.PublicKeyString), + new CommandParameter("ip", string.Empty) }, + TS3Command.NoOptions); + var textBytes = Util.Encoder.GetBytes(initAdd); + + // Prepare solution + int level = NetUtil.N2Hint(data, initTypeLen + 128); + byte[] y = SolveRsaChallange(data, initTypeLen, level); + + // Copy bytes for this result: [Version..., InitType..., data..., y..., text...] + var sendData = new byte[versionLen + initTypeLen + 232 + 64 + textBytes.Length]; + // Copy this.Version + Array.Copy(Initversion, 0, sendData, 0, versionLen); + // Write InitType + sendData[versionLen] = 0x04; + // Copy data + Array.Copy(data, initTypeLen, sendData, versionLen + initTypeLen, 232); + // Copy y + Array.Copy(y, 0, sendData, versionLen + initTypeLen + 232, 64); + // Copy text + Array.Copy(textBytes, 0, sendData, versionLen + initTypeLen + 232 + 64, textBytes.Length); + + return sendData; + } + else + return null; + } + + /// This method calculates x ^ (2^level) % n = y which is the solution to the server RSA puzzle. + /// The data array, containing x=[0,64] and n=[64,128], each unsigned, as a BigInteger bytearray. + /// The offset of x and n in the data array. + /// The exponent to x. + /// The y value, unsigned, as a BigInteger bytearray. + private static byte[] SolveRsaChallange(byte[] data, int offset, int level) + { + // x is the base, n is the modulus. + var x = new BigInteger(1, data, 00 + offset, 64); + var n = new BigInteger(1, data, 64 + offset, 64); + return x.ModPow(BigInteger.Two.Pow(level), n).ToByteArrayUnsigned(); + } + + #endregion + + #region ENCRYPTION/DECRYPTION + + public bool Encrypt(OutgoingPacket packet) + { + if (packet.PacketType == PacketType.Init1) + { + FakeEncrypt(packet, TS3InitMac); + return true; + } + if (packet.UnencryptedFlag) + { + FakeEncrypt(packet, fakeSignature); + return true; + } + + var keyNonce = GetKeyNonce(false, packet.PacketId, 0, packet.PacketType); + packet.BuildHeader(); + ICipherParameters ivAndKey = new AeadParameters(new KeyParameter(keyNonce.Item1), 8 * MacLen, keyNonce.Item2, packet.Header); + + eaxCipher.Init(true, ivAndKey); + byte[] result = new byte[eaxCipher.GetOutputSize(packet.Size)]; + int len; + try + { + len = eaxCipher.ProcessBytes(packet.Data, 0, packet.Size, result, 0); + len += eaxCipher.DoFinal(result, len); + } + catch (Exception) { return false; } + + // cryptOutArr consists of [Data..., Mac...] + // to build the final TS3/libtomcrypt we need to copy it into another order + + // len is Data.Length + Mac.Length + packet.Raw = new byte[OutHeaderLen + len]; + // Copy the Mac from [Data..., Mac...] to [Mac..., Header..., Data...] + Array.Copy(result, len - MacLen, packet.Raw, 0, MacLen); + // Copy the Header from packet.Header to [Mac..., Header..., Data...] + Array.Copy(packet.Header, 0, packet.Raw, MacLen, OutHeaderLen); + // Copy the Data from [Data..., Mac...] to [Mac..., Header..., Data...] + Array.Copy(result, 0, packet.Raw, MacLen + OutHeaderLen, len - MacLen); + // Raw is now [Mac..., Header..., Data...] + return true; + } + + private void FakeEncrypt(OutgoingPacket packet, byte[] mac) + { + packet.BuildHeader(); + packet.Raw = new byte[packet.Data.Length + MacLen + OutHeaderLen]; + // Copy the Mac from [Data..., Mac...] to [Mac..., Header..., Data...] + Array.Copy(mac, 0, packet.Raw, 0, MacLen); + // Copy the Header from this.Header to [Mac..., Header..., Data...] + Array.Copy(packet.Header, 0, packet.Raw, MacLen, OutHeaderLen); + // Copy the Data from [Data..., Mac...] to [Mac..., Header..., Data...] + Array.Copy(packet.Data, 0, packet.Raw, MacLen + OutHeaderLen, packet.Data.Length); + // Raw is now [Mac..., Header..., Data...] + } + + public IncomingPacket Decrypt(byte[] data) + { + if (data.Length < InHeaderLen + MacLen) + return null; + + var packet = new IncomingPacket(data) + { + PacketTypeFlagged = data[MacLen + 2], + PacketId = NetUtil.N2Hushort(data, MacLen), + }; + + if (packet.PacketType == PacketType.Init1) + { + if (!FakeDecrypt(packet, TS3InitMac)) + return null; + } + else + { + if (packet.UnencryptedFlag) + { + if (!FakeDecrypt(packet, fakeSignature)) + return null; + } + else + { + if (!Decrypt(packet)) + return null; + } + } + + return packet; + } + + private bool Decrypt(IncomingPacket packet) + { + Array.Copy(packet.Raw, MacLen, packet.Header, 0, InHeaderLen); + var keyNonce = GetKeyNonce(true, packet.PacketId, 0, packet.PacketType); + int dataLen = packet.Raw.Length - (MacLen + InHeaderLen); + + ICipherParameters ivAndKey = new AeadParameters(new KeyParameter(keyNonce.Item1), 8 * MacLen, keyNonce.Item2, packet.Header); + eaxCipher.Init(false, ivAndKey); + byte[] result = new byte[eaxCipher.GetOutputSize(dataLen + MacLen)]; + try + { + byte[] comb = new byte[dataLen + MacLen]; + Array.Copy(packet.Raw, MacLen + InHeaderLen, comb, 0, dataLen); + Array.Copy(packet.Raw, 0, comb, dataLen, MacLen); + + int len2 = eaxCipher.ProcessBytes(comb, 0, comb.Length, result, 0); + len2 += eaxCipher.DoFinal(result, len2); + + int len = eaxCipher.ProcessBytes(packet.Raw, MacLen + InHeaderLen, dataLen, result, 0); + len += eaxCipher.ProcessBytes(packet.Raw, 0, MacLen, result, len); + len += eaxCipher.DoFinal(result, len); + } + catch (Exception) { return false; } + packet.Data = result; + return true; + } + + private static bool FakeDecrypt(IncomingPacket packet, byte[] mac) + { + if (!CheckEqual(packet.Raw, 0, mac, 0, MacLen)) + return false; + int dataLen = packet.Raw.Length - (MacLen + InHeaderLen); + packet.Data = new byte[dataLen]; + Array.Copy(packet.Raw, MacLen + InHeaderLen, packet.Data, 0, dataLen); + return true; + } + + /// TS3 uses a new key and nonce for each packet sent and received. This method generates and caches these. + /// True if the packet is from server to client, false for client to server. + /// The id of the packet, host order. + /// Seriously no idea, just pass 0 and it should be fine. + /// The packetType. + /// + private Tuple GetKeyNonce(bool fromServer, ushort packetId, uint generationId, PacketType packetType) + { + if (!CryptoInitComplete) + return DummyKeyAndNonceTuple; + + // only the lower 4 bits are used for the real packetType + byte packetTypeRaw = (byte)packetType; + + int cacheIndex = packetTypeRaw * (fromServer ? 1 : 2); + if (cachedKeyNonces[cacheIndex] == null) + { + // this part of the key/nonce is fixed by the message direction and packetType + byte[] tmpToHash = new byte[26]; + + if (fromServer) + tmpToHash[0] = 0x30; + else + tmpToHash[0] = 0x31; + + tmpToHash[1] = packetTypeRaw; + + Array.Copy(BitConverter.GetBytes(NetUtil.H2N(generationId)), 0, tmpToHash, 2, 4); + Array.Copy(ivStruct, 0, tmpToHash, 6, 20); + + var result = Hash256It(tmpToHash); + + cachedKeyNonces[cacheIndex] = new Tuple(result.Slice(0, 16).ToArray(), result.Slice(16, 16).ToArray()); + } + + byte[] key = new byte[16]; + byte[] nonce = new byte[16]; + Array.Copy(cachedKeyNonces[cacheIndex].Item1, 0, key, 0, 16); + Array.Copy(cachedKeyNonces[cacheIndex].Item2, 0, nonce, 0, 16); + + // finally the first two bytes get xor'd with the packet id + // TODO: this could be written more efficiently + var startData = NetUtil.N2H(BitConverter.ToUInt16(key, 0)); + startData = (ushort)(startData ^ packetId); + var xordata = BitConverter.GetBytes(NetUtil.H2N(startData)); + Array.Copy(xordata, 0, key, 0, xordata.Length); + + return new Tuple(key, nonce); + } + + #endregion + + #region CRYPT HELPER + + private static bool CheckEqual(byte[] a1, int a1Index, byte[] a2, int a2Index, int len) + { + for (int i = 0; i < len; i++) + if (a1[i + a1Index] != a2[i + a2Index]) return false; + return true; + } + + public static void XorBinary(byte[] a, byte[] b, int len, byte[] outBuf) + { + if (a.Length < len || b.Length < len || outBuf.Length < len) throw new ArgumentException(); + for (int i = 0; i < len; i++) + outBuf[i] = (byte)(a[i] ^ b[i]); + } + + private static readonly Sha1Digest Sha1Hash = new Sha1Digest(); + private static readonly Sha256Digest Sha256Hash = new Sha256Digest(); + private static byte[] Hash1It(byte[] data, int offset = 0, int len = 0) => HashIt(Sha1Hash, data, offset, len); + private static byte[] Hash256It(byte[] data, int offset = 0, int len = 0) => HashIt(Sha256Hash, data, offset, len); + private static byte[] HashIt(GeneralDigest hashAlgo, byte[] data, int offset = 0, int len = 0) + { + byte[] result; + lock (hashAlgo) + { + hashAlgo.Reset(); + hashAlgo.BlockUpdate(data, offset, len == 0 ? data.Length - offset : len); + result = new byte[hashAlgo.GetDigestSize()]; + hashAlgo.DoFinal(result, 0); + } + return result; + } + + #endregion + + #region IDENTITY & SECURITY LEVEL + + public void ImproveSecurity(int toLevel) + { + byte[] hashBuffer = new byte[Identity.PublicKeyString.Length + ulong.MaxValue.ToString().Length]; + byte[] pubKeyBytes = Encoding.ASCII.GetBytes(Identity.PublicKeyString); + Array.Copy(pubKeyBytes, 0, hashBuffer, 0, pubKeyBytes.Length); + + int best = GetSecurityLevel(hashBuffer, pubKeyBytes.Length, Identity.ValidKeyOffset); + while (true) + { + if (best >= toLevel) return; + + var numberBytes = Encoding.ASCII.GetBytes(Identity.LastCheckedKeyOffset.ToString()); + Array.Copy(numberBytes, 0, hashBuffer, pubKeyBytes.Length, numberBytes.Length); + byte[] outHash = Hash1It(hashBuffer, 0, pubKeyBytes.Length + numberBytes.Length); + + int curr = GetSecurityLevel(hashBuffer, pubKeyBytes.Length, Identity.LastCheckedKeyOffset); + if (curr > best) + { + Identity.ValidKeyOffset = Identity.LastCheckedKeyOffset; + best = curr; + } + Identity.LastCheckedKeyOffset++; + } + } + + public void GenerateNewIdentity(int securityLevel = 8) + { + var ecp = ECNamedCurveTable.GetByName("prime256v1"); + var domainParams = new ECDomainParameters(ecp.Curve, ecp.G, ecp.N, ecp.H, ecp.GetSeed()); + var keyGenParams = new ECKeyGenerationParameters(domainParams, new SecureRandom()); + var generator = new ECKeyPairGenerator(); + generator.Init(keyGenParams); + var keyPair = generator.GenerateKeyPair(); + + var privateKey = (ECPrivateKeyParameters)keyPair.Private; + var publicKey = (ECPublicKeyParameters)keyPair.Public; + + var pubPrivKey = new Tuple(publicKey.Q.Normalize(), privateKey.D); + LoadIdentity(pubPrivKey, 0, 0); + ImproveSecurity(securityLevel); + } + + private int GetSecurityLevel(byte[] hashBuffer, int pubKeyLen, ulong level) + { + var numberBytes = Encoding.ASCII.GetBytes(Identity.LastCheckedKeyOffset.ToString()); + Array.Copy(numberBytes, 0, hashBuffer, pubKeyLen, numberBytes.Length); + byte[] outHash = Hash1It(hashBuffer, 0, pubKeyLen + numberBytes.Length); + return GetLeadingZeroBits(outHash); + } + + private int GetLeadingZeroBits(byte[] data) + { + int curr = 0; + int i; + for (i = 0; i < data.Length; i++) + if (data[i] == 0) curr += 8; + else break; + for (int bit = 7; bit >= 0; bit--) + if ((data[i] & (1 << bit)) == 0) curr++; + else break; + return curr; + } + + #endregion + } +} diff --git a/TS3Client/Full/TS3FullClient.cs b/TS3Client/Full/TS3FullClient.cs new file mode 100644 index 00000000..71bec60f --- /dev/null +++ b/TS3Client/Full/TS3FullClient.cs @@ -0,0 +1,174 @@ +namespace TS3Client.Full +{ + using System; + using System.Collections.Generic; + using System.Net.Sockets; + using Messages; + + public sealed class TS3FullClient : TS3BaseClient + { + private readonly UdpClient udpClient; + private readonly TS3Crypt ts3Crypt; + private readonly PacketHandler packetHandler; + + private int returnCode; + + public override ClientType ClientType => ClientType.Full; + + public TS3FullClient(EventDispatchType dispatcher) : base(dispatcher) + { + udpClient = new UdpClient(); + ts3Crypt = new TS3Crypt(); + packetHandler = new PacketHandler(ts3Crypt, udpClient); + + returnCode = 0; + } + + protected override void ConnectInternal(ConnectionData conData) + { + Reset(); + + packetHandler.Start(); + + try { udpClient.Connect(conData.Hostname, conData.Port); } + catch (SocketException ex) { throw new TS3CommandException(new CommandError(), ex); } + + ts3Crypt.LoadIdentity(conData.PrivateKey, conData.KeyOffset, conData.LastCheckedKeyOffset); + var initData = ts3Crypt.ProcessInit1(null); + packetHandler.AddOutgoingPacket(initData, PacketType.Init1); + } + + protected override void DisconnectInternal() + { + ClientDisconnect(MoveReason.LeftServer, "Disconnected"); + //udpClient.Close(); + } + + protected override void NetworkLoop() + { + while (true) + { + var packet = packetHandler.FetchPacket(); + if (packet == null) break; + + switch (packet.PacketType) + { + case PacketType.Command: + string message = Util.Encoder.GetString(packet.Data, 0, packet.Data.Length); + if (!SpecialCommandProcess(message)) + ProcessCommand(message); + break; + + case PacketType.Readable: + // VOICE + + break; + + case PacketType.Init1: + var forwardData = ts3Crypt.ProcessInit1(packet.Data); + packetHandler.AddOutgoingPacket(forwardData, PacketType.Init1); + break; + } + } + Status = TS3ClientStatus.Disconnected; + } + + private bool SpecialCommandProcess(string message) + { + if (message.StartsWith("initivexpand ", StringComparison.Ordinal) + || message.StartsWith("initserver ", StringComparison.Ordinal) + || message.StartsWith("channellist ", StringComparison.Ordinal) + || message.StartsWith("channellistfinished ", StringComparison.Ordinal)) + { + var notification = CommandDeserializer.GenerateNotification(message); + InvokeEvent(notification.Item1, notification.Item2); + return true; + } + return false; + } + + protected override void ProcessInitIvExpand(InitIvExpand initIvExpand) + { + ts3Crypt.CryptoInit(initIvExpand.Alpha, initIvExpand.Beta, initIvExpand.Omega); + packetHandler.CryptoInitDone(); + ClientInit( + ConnectionData.UserName, + "Windows", + true, true, + string.Empty, string.Empty, + ConnectionData.Password, + string.Empty, string.Empty, string.Empty, "123,456", + VersionSign.VER_3_0_19_03); + } + + protected override void ProcessInitServer(InitServer initServer) + { + packetHandler.ClientId = initServer.ClientId; + ConnectDone(); + } + + protected override IEnumerable SendCommand(TS3Command com, Type targetType) + { + if (com.ExpectResponse) + com.AppendParameter(new CommandParameter("return_code", returnCode)); + + using (WaitBlock wb = new WaitBlock(targetType)) + { + lock (LockObj) + { + if (com.ExpectResponse) + { + RequestQueue.Enqueue(wb); + returnCode++; + } + + byte[] data = Util.Encoder.GetBytes(com.ToString()); + packetHandler.AddOutgoingPacket(data, PacketType.Command); + } + + if (com.ExpectResponse) + return wb.WaitForMessage(); + else + return null; + } + } + + protected override void Reset() + { + base.Reset(); + + ts3Crypt.Reset(); + packetHandler.Reset(); + + returnCode = 0; + } + + #region FULLCLIENT SPECIFIC COMMANDS + + public void ClientInit(string nickname, string plattform, bool inputHardware, bool outputHardware, + string defaultChannel, string defaultChannelPassword, string serverPassword, string metaData, + string nicknamePhonetic, string defaultToken, string hwid, VersionSign versionSign) + => SendNoResponsed( + new TS3Command("clientinit", new List() { + new CommandParameter("client_nickname", nickname), + new CommandParameter("client_version", versionSign.Name), + new CommandParameter("client_platform", plattform), + new CommandParameter("client_input_hardware", inputHardware), + new CommandParameter("client_output_hardware", outputHardware), + new CommandParameter("client_default_channel", defaultChannel), + new CommandParameter("client_default_channel_password", defaultChannelPassword), + new CommandParameter("client_server_password", serverPassword), + new CommandParameter("client_meta_data", metaData), + new CommandParameter("client_version_sign", versionSign.Sign), + new CommandParameter("client_key_offset", ts3Crypt.Identity.ValidKeyOffset), + new CommandParameter("client_nickname_phonetic", nicknamePhonetic), + new CommandParameter("client_default_token", defaultToken), + new CommandParameter("hwid", hwid) })); + + public void ClientDisconnect(MoveReason reason, string reasonMsg) + => Send("clientdisconnect", + new CommandParameter("reasonid", (int)reason), + new CommandParameter("reasonmsg", reasonMsg)); + #endregion + } +} diff --git a/TS3Client/Full/VersionSign.cs b/TS3Client/Full/VersionSign.cs new file mode 100644 index 00000000..768b27a7 --- /dev/null +++ b/TS3Client/Full/VersionSign.cs @@ -0,0 +1,19 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace TS3Client.Full +{ + public class VersionSign + { + public string Sign { get; } + public string Name { get; } + + public VersionSign(string name, string sign) { Name = name; Sign = sign; } + + public static readonly VersionSign VER_3_0_19_03 + = new VersionSign("3.0.19.3 [Build: 1466672534]", "a1OYzvM18mrmfUQBUgxYBxYz2DUU6y5k3/mEL6FurzU0y97Bd1FL7+PRpcHyPkg4R+kKAFZ1nhyzbgkGphDWDg=="); + } +} diff --git a/TS3AudioBot/TS3Query/Messages/BaseTypes.cs b/TS3Client/Messages/BaseTypes.cs similarity index 92% rename from TS3AudioBot/TS3Query/Messages/BaseTypes.cs rename to TS3Client/Messages/BaseTypes.cs index c44505e3..abfcf8a4 100644 --- a/TS3AudioBot/TS3Query/Messages/BaseTypes.cs +++ b/TS3Client/Messages/BaseTypes.cs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query.Messages +namespace TS3Client.Messages { using System; @@ -25,7 +25,11 @@ public interface INotification : IQueryMessage NotificationType NotifyType { get; } } - public interface IResponse : IQueryMessage { } + public interface IResponse : IQueryMessage + { + [QuerySerialized("return_code")] + string ReturnCode { get; set; } + } [AttributeUsage(AttributeTargets.Interface, Inherited = true, AllowMultiple = false)] sealed class QuerySubInterfaceAttribute : Attribute { } diff --git a/TS3AudioBot/TS3Query/Messages/ChannelNotifications.cs b/TS3Client/Messages/ChannelNotifications.cs similarity index 98% rename from TS3AudioBot/TS3Query/Messages/ChannelNotifications.cs rename to TS3Client/Messages/ChannelNotifications.cs index 1223630a..097777d5 100644 --- a/TS3AudioBot/TS3Query/Messages/ChannelNotifications.cs +++ b/TS3Client/Messages/ChannelNotifications.cs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query.Messages +namespace TS3Client.Messages { using System; @@ -22,7 +22,7 @@ namespace TS3Query.Messages public interface IChannelId { [QuerySerialized("cid")] - int ChannelId { get; set; } + ulong ChannelId { get; set; } } [QuerySubInterface] diff --git a/TS3AudioBot/TS3Query/Messages/ClientNotifications.cs b/TS3Client/Messages/ClientNotifications.cs similarity index 95% rename from TS3AudioBot/TS3Query/Messages/ClientNotifications.cs rename to TS3Client/Messages/ClientNotifications.cs index 92134211..617ec6bb 100644 --- a/TS3AudioBot/TS3Query/Messages/ClientNotifications.cs +++ b/TS3Client/Messages/ClientNotifications.cs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query.Messages +namespace TS3Client.Messages { using System; @@ -22,7 +22,7 @@ namespace TS3Query.Messages public interface ITargetChannelId { [QuerySerialized("ctid")] - int TargetChannelId { get; set; } + ulong TargetChannelId { get; set; } } [QuerySubInterface] @@ -32,6 +32,13 @@ public interface IClientId ushort ClientId { get; set; } } + [QuerySubInterface] + public interface IClientUid + { + [QuerySerialized("cluid")] + string ClientUid { get; set; } + } + [QuerySubInterface] public interface IClientDbId { @@ -50,7 +57,7 @@ public interface IClientUidLong public interface ISourceChannelId { [QuerySerialized("cfid")] - int SourceChannelId { get; set; } + ulong SourceChannelId { get; set; } } [QuerySubInterface] @@ -117,7 +124,7 @@ public interface ClientServerGroup : IResponse, IClientDbId string Name { get; set; } [QuerySerialized("sgid")] - int ServerGroupId { get; set; } + ulong ServerGroupId { get; set; } } [QueryNotification(NotificationType.ClientEnterView)] diff --git a/TS3Client/Messages/FullClientNotifications.cs b/TS3Client/Messages/FullClientNotifications.cs new file mode 100644 index 00000000..b0e8e1a2 --- /dev/null +++ b/TS3Client/Messages/FullClientNotifications.cs @@ -0,0 +1,128 @@ +namespace TS3Client.Messages +{ + [QueryNotification(NotificationType.InitIvExpand)] + public interface InitIvExpand : INotification + { + [QuerySerialized("alpha")] + string Alpha { get; set; } + + [QuerySerialized("beta")] + string Beta { get; set; } + + [QuerySerialized("omega")] + string Omega { get; set; } + } + + [QueryNotification(NotificationType.InitServer)] + public interface InitServer : INotification, IServerName, ServerBaseData2 + { + [QuerySerialized("virtualserver_welcomemessage")] + string WelcomeMessage { get; set; } + + [QuerySerialized("virtualserver_platform")] + string ServerPlatform { get; set; } + + [QuerySerialized("virtualserver_version")] + string ServerVersion { get; set; } + + [QuerySerialized("virtualserver_maxclients")] + ushort MaxClients { get; set; } + + [QuerySerialized("virtualserver_created")] + long ServerCreated { get; set; } // ? + + [QuerySerialized("virtualserver_hostmessage")] + string Hostmessage { get; set; } + + [QuerySerialized("virtualserver_hostmessage_mode")] + HostMessageMode HostmessageMode { get; set; } + + [QuerySerialized("virtualserver_id")] + ulong ServerId { get; set; } + + [QuerySerialized("virtualserver_ip")] + string ServerIp { get; set; } + + [QuerySerialized("virtualserver_ask_for_privilegekey")] + bool AskForPrivilege { get; set; } + + [QuerySerialized("acn")] + string ClientName { get; set; } + + [QuerySerialized("aclid")] + ushort ClientId { get; set; } + + [QuerySerialized("pv")] + int Pv { get; set; } // ? + + [QuerySerialized("lt")] + int Lt { get; set; } // ? + + [QuerySerialized("client_talk_power")] + int ClientTalkpower { get; set; } + + [QuerySerialized("client_needed_serverquery_view_power")] + string NeededServerqueryViewpower { get; set; } + } + + [QueryNotification(NotificationType.ChannelList)] + public interface ChannelList : INotification, IChannelParentId, IChannelId, IChannelBaseData, IChannelNotificationData + { + [QuerySerialized("channel_forced_silence")] + bool ForcedSilence { get; set; } + + [QuerySerialized("channel_flag_private")] + bool IsPrivate { get; set; } + } + + [QueryNotification(NotificationType.ChannelListFinished)] + public interface ChannelListFinished : INotification { } + + [QueryNotification(NotificationType.ClientNeededPermissions)] + public interface ClientNeededPermissions : INotification + { + [QuerySerialized("permid")] + int PermissionId { get; set; } + + [QuerySerialized("permvalue")] + int PermissionValue { get; set; } + } + + [QueryNotification(NotificationType.ClientChannelGroupChanged)] + public interface ClientChannelGroupChanged : INotification, IChannelId, IClientId + { + [QuerySerialized("invokerid")] + ushort InvokerId { get; set; } + + [QuerySerialized("invokername")] + string InvokerName { get; set; } + + [QuerySerialized("cgid")] + ulong ChannelGroupId { get; set; } + + [QuerySerialized("cgi")] + ulong ChannelGroupIndex { get; set; } // always same as ChannelId ??!? + } + + [QueryNotification(NotificationType.ClientServerGroupAdded)] + public interface ClientServerGroupAdded : INotification, IInvokedNotification, IClientId, IClientUid + { + [QuerySerialized("name")] + string Name { get; set; } + + [QuerySerialized("sgid")] + ulong ServerGroupId { get; set; } + } + + [QueryNotification(NotificationType.ConnectionInfoRequest)] + public interface ConnectionInfoRequest : INotification { } + + [QueryNotification(NotificationType.ChannelSubscribed)] + public interface ChannelSubscribed : INotification, IChannelId { } + + [QueryNotification(NotificationType.ChannelUnsubscribed)] + public interface ChannelUnsubscribed : INotification, IChannelId { } + + [QueryNotification(NotificationType.ClientChatComposing)] + public interface ClientChatComposing : INotification, IClientId, IClientUid { } +} \ No newline at end of file diff --git a/TS3AudioBot/TS3Query/Messages/Generator.cs b/TS3Client/Messages/Generator.cs similarity index 95% rename from TS3AudioBot/TS3Query/Messages/Generator.cs rename to TS3Client/Messages/Generator.cs index c045b5d8..c7d7df53 100644 --- a/TS3AudioBot/TS3Query/Messages/Generator.cs +++ b/TS3Client/Messages/Generator.cs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query.Messages +namespace TS3Client.Messages { using System; using System.Collections.Generic; @@ -24,7 +24,7 @@ namespace TS3Query.Messages public static class Generator { - private static readonly Dictionary generatedTypes; + private static readonly Dictionary GeneratedTypes; private static readonly AssemblyName GenAssemblyName = new AssemblyName("QueryMessages"); private static readonly AssemblyBuilder GenAssemblyBuilder = AppDomain.CurrentDomain.DefineDynamicAssembly(GenAssemblyName, AssemblyBuilderAccess.Run); private static readonly ModuleBuilder GenModuleBuilder = GenAssemblyBuilder.DefineDynamicModule("MainModule"); @@ -32,7 +32,7 @@ public static class Generator static Generator() { - Helper.Init(ref generatedTypes); + Util.Init(ref GeneratedTypes); } public static T ActivateNotification() where T : INotification => (T)ActivateNotification(typeof(T)); @@ -41,15 +41,15 @@ static Generator() public static T ActivateResponse() where T : IResponse => (T)ActivateResponse(typeof(T)); public static IResponse ActivateResponse(Type t) => (IResponse)Activate(t, false); - public static Dictionary GetAccessMap(Type t) => generatedTypes[t].AccessMap; + public static Dictionary GetAccessMap(Type t) => GeneratedTypes[t].AccessMap; private static object Activate(Type backingInterface, bool notifyProp) { InitializerData genType; - if (!generatedTypes.TryGetValue(backingInterface, out genType)) + if (!GeneratedTypes.TryGetValue(backingInterface, out genType)) { genType = Generate(backingInterface, notifyProp); - generatedTypes.Add(backingInterface, genType); + GeneratedTypes.Add(backingInterface, genType); } return Activator.CreateInstance(genType.ActivationType); } diff --git a/TS3AudioBot/TS3Query/Messages/OtherNotifications.cs b/TS3Client/Messages/OtherNotifications.cs similarity index 88% rename from TS3AudioBot/TS3Query/Messages/OtherNotifications.cs rename to TS3Client/Messages/OtherNotifications.cs index a2ea9e1e..f9d1ab6a 100644 --- a/TS3AudioBot/TS3Query/Messages/OtherNotifications.cs +++ b/TS3Client/Messages/OtherNotifications.cs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query.Messages +namespace TS3Client.Messages { using System; @@ -25,17 +25,17 @@ public interface IServerName string ServerName { get; set; } } - [QueryNotification(NotificationType.ServerEdited)] - public interface ServerEdited : INotification, IInvokedNotification, IReason, IServerName + [QuerySubInterface] + public interface ServerBaseData2 { [QuerySerialized("virtualserver_codec_encryption_mode")] CodecEncryptionMode CodecEncryptionMode { get; set; } [QuerySerialized("virtualserver_default_server_group")] - int DefaultServerGroup { get; set; } + ulong DefaultServerGroup { get; set; } [QuerySerialized("virtualserver_default_channel_group")] - int DefaultChannelGroup { get; set; } + ulong DefaultChannelGroup { get; set; } [QuerySerialized("virtualserver_hostbanner_url")] string HostbannerUrl { get; set; } @@ -50,27 +50,30 @@ public interface ServerEdited : INotification, IInvokedNotification, IReason, IS float PrioritySpeakerDimmModificator { get; set; } [QuerySerialized("virtualserver_hostbutton_tooltip")] - string HostButtonTooltipText { get; set; } + string HostbuttonTooltip { get; set; } [QuerySerialized("virtualserver_hostbutton_url")] - string HostButtonUrl { get; set; } + string HostbuttonUrl { get; set; } [QuerySerialized("virtualserver_hostbutton_gfx_url")] - string HostButtonGfxUrl { get; set; } + string HostbuttonGfxUrl { get; set; } [QuerySerialized("virtualserver_name_phonetic")] string PhoneticName { get; set; } [QuerySerialized("virtualserver_icon_id")] - long IconId { get; set; } + ulong IconId { get; set; } [QuerySerialized("virtualserver_hostbanner_mode")] HostBannerMode HostbannerMode { get; set; } [QuerySerialized("virtualserver_channel_temp_delete_delay_default")] - TimeSpan TempChannelDefaultDeleteDelay { get; set; } + TimeSpan DefaultTempChannelDeleteDelay { get; set; } } + [QueryNotification(NotificationType.ServerEdited)] + public interface ServerEdited : INotification, IInvokedNotification, IReason, IServerName, ServerBaseData2 { } + [QueryNotification(NotificationType.TextMessage)] public interface TextMessage : INotification, IInvokedNotification { @@ -85,11 +88,8 @@ public interface TextMessage : INotification, IInvokedNotification } [QueryNotification(NotificationType.TokenUsed)] - public interface TokenUsed : INotification, IClientId, IClientDbId + public interface TokenUsed : INotification, IClientId, IClientDbId, IClientUid { - [QuerySerialized("cluid")] - string ClientUid { get; set; } - [QuerySerialized("token")] string UsedToken { get; set; } @@ -107,7 +107,7 @@ public interface TokenUsed : INotification, IClientId, IClientDbId public interface ServerBaseData { [QuerySerialized("virtualserver_id")] - int VirtualServerId { get; set; } + ulong VirtualServerId { get; set; } [QuerySerialized("virtualserver_unique_identifier")] string VirtualServerUid { get; set; } @@ -118,7 +118,7 @@ public interface ServerBaseData [QuerySerialized("virtualserver_status")] string VirtualServerStatus { get; set; } } - + public interface ServerData : IResponse, IServerName, ServerBaseData { [QuerySerialized("virtualserver_clientsonline")] @@ -128,7 +128,7 @@ public interface ServerData : IResponse, IServerName, ServerBaseData int QueriesOnline { get; set; } [QuerySerialized("virtualserver_maxclients")] - int MaxClients { get; set; } + ushort MaxClients { get; set; } [QuerySerialized("virtualserver_uptime")] TimeSpan Uptime { get; set; } @@ -139,14 +139,14 @@ public interface ServerData : IResponse, IServerName, ServerBaseData [QuerySerialized("virtualserver_machine_id")] string MachineId { get; set; } } - + public interface WhoAmI : IResponse, ServerBaseData, IClientUidLong { [QuerySerialized("client_id")] ushort ClientId { get; set; } [QuerySerialized("client_channel_id")] - int ChannelId { get; set; } + ulong ChannelId { get; set; } [QuerySerialized("client_nickname")] string NickName { get; set; } @@ -158,6 +158,6 @@ public interface WhoAmI : IResponse, ServerBaseData, IClientUidLong string LoginName { get; set; } [QuerySerialized("client_origin_server_id")] - int OriginServerId { get; set; } + ulong OriginServerId { get; set; } } } diff --git a/TS3AudioBot/TS3Query/Messages/ResponseDictionary.cs b/TS3Client/Messages/ResponseDictionary.cs similarity index 84% rename from TS3AudioBot/TS3Query/Messages/ResponseDictionary.cs rename to TS3Client/Messages/ResponseDictionary.cs index c37a7484..f1670a29 100644 --- a/TS3AudioBot/TS3Query/Messages/ResponseDictionary.cs +++ b/TS3Client/Messages/ResponseDictionary.cs @@ -14,7 +14,7 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query.Messages +namespace TS3Client.Messages { using System; using System.Collections; @@ -22,9 +22,9 @@ namespace TS3Query.Messages using KeyType = System.String; using ValueType = System.String; - class ResponseDictionary : IDictionary, IResponse + public class ResponseDictionary : IDictionary, IResponse { - private IDictionary data; + private readonly IDictionary data; public ResponseDictionary(IDictionary dataDict) { data = dataDict; } public ValueType this[KeyType key] { get { return data[key]; } set { throw new NotSupportedException(); } } @@ -43,5 +43,15 @@ class ResponseDictionary : IDictionary, IResponse public bool Remove(KeyType key) { throw new NotSupportedException(); } public bool TryGetValue(KeyType key, out ValueType value) => data.TryGetValue(key, out value); IEnumerator IEnumerable.GetEnumerator() => data.GetEnumerator(); + + public string ReturnCode + { + get { return data.ContainsKey("return_code") ? data["return_code"] : string.Empty; } + set + { + if (data.ContainsKey("return_code")) data["return_code"] = value; + else data.Add("return_code", value); + } + } } } diff --git a/TS3Client/OwnEnums.cs b/TS3Client/OwnEnums.cs new file mode 100644 index 00000000..d7a92d3a --- /dev/null +++ b/TS3Client/OwnEnums.cs @@ -0,0 +1,138 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + using System; + + /* + * Most important Id datatypes: + * + * ClientUid: ulong + * ClientId: ushort + * ChannelId: ulong + * ServerGroupId: ulong + * ChannelGroupId: ulong + */ + + public enum ClientType + { + Full = 0, + Query, + } + + [Flags] + public enum ClientListOptions + { + uid = 1 << 0, + away = 1 << 1, + voice = 1 << 2, + times = 1 << 3, + groups = 1 << 4, + info = 1 << 5, + icon = 1 << 6, + country = 1 << 7, + } + + public enum MessageTarget + { + [TS3Serializable("textprivate")] + Private = 1, + [TS3Serializable("textchannel")] + Channel, + [TS3Serializable("textserver")] + Server, + } + + public enum RequestTarget + { + [TS3Serializable("channel")] + Channel = 4, + [TS3Serializable("server")] + Server, + } + + public enum NotificationType + { + Unknown, + // Official notifies, used by client and query + [TS3Serializable("notifychannelcreated")] + ChannelCreated, + [TS3Serializable("notifychanneldeleted")] + ChannelDeleted, + [TS3Serializable("notifychannelchanged")] + ChannelChanged, + [TS3Serializable("notifychanneledited")] + ChannelEdited, + [TS3Serializable("notifychannelmoved")] + ChannelMoved, + [TS3Serializable("notifychannelpasswordchanged")] + ChannelPasswordChanged, + [TS3Serializable("notifycliententerview")] + ClientEnterView, + [TS3Serializable("notifyclientleftview")] + ClientLeftView, + [TS3Serializable("notifyclientmoved")] + ClientMoved, + [TS3Serializable("notifyserveredited")] + ServerEdited, + [TS3Serializable("notifytextmessage")] + TextMessage, + [TS3Serializable("notifytokenused")] + TokenUsed, + + // Internal notifies, used by client + [TS3Serializable("initivexpand")] + InitIvExpand, + [TS3Serializable("initserver")] + InitServer, + [TS3Serializable("channellist")] + ChannelList, + [TS3Serializable("channellistfinished")] + ChannelListFinished, + [TS3Serializable("notifyclientneededpermissions")] + ClientNeededPermissions, + [TS3Serializable("notifyclientchannelgroupchanged")] + ClientChannelGroupChanged, + [TS3Serializable("notifyservergroupclientadded")] + ClientServerGroupAdded, + [TS3Serializable("notifyconnectioninforequest")] + ConnectionInfoRequest, + [TS3Serializable("notifychannelsubscribed")] + ChannelSubscribed, + [TS3Serializable("notifychannelunsubscribed")] + ChannelUnsubscribed, + [TS3Serializable("notifyclientchatcomposing")] + ClientChatComposing, + // TODO: notifyservergroupsbyclientid + } + + public enum MoveReason + { + UserAction = 0, + UserOrChannelMoved, + SubscriptionChanged, + Timeout, + KickedFromChannel, + KickedFromServer, + Banned, + ServerStopped, + LeftServer, + ChannelUpdated, + ServerOrChannelEdited, + ServerShutdown, + } +} diff --git a/TS3Client/ParameterConverter.cs b/TS3Client/ParameterConverter.cs new file mode 100644 index 00000000..4af69240 --- /dev/null +++ b/TS3Client/ParameterConverter.cs @@ -0,0 +1,62 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + using System; + using System.Globalization; + + public interface IParameterConverter + { + string QueryValue { get; } + } + + public class PrimitiveParameter : IParameterConverter + { + public string QueryValue { get; } + public static readonly DateTime UnixTimeStart = new DateTime(1970, 1, 1, 0, 0, 0, 0, DateTimeKind.Utc); + + public PrimitiveParameter(bool value) { QueryValue = (value ? "1" : "0"); } + public PrimitiveParameter(sbyte value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(byte value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(short value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(ushort value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(int value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(uint value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(long value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(ulong value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(float value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(double value) { QueryValue = value.ToString(CultureInfo.InvariantCulture); } + public PrimitiveParameter(string value) { QueryValue = TS3String.Escape(value); } + public PrimitiveParameter(TimeSpan value) { QueryValue = value.TotalSeconds.ToString("F0", CultureInfo.InvariantCulture); } + public PrimitiveParameter(DateTime value) { QueryValue = (value - UnixTimeStart).TotalSeconds.ToString("F0", CultureInfo.InvariantCulture); } + + public static implicit operator PrimitiveParameter(bool value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(sbyte value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(byte value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(short value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(ushort value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(int value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(uint value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(long value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(ulong value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(float value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(double value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(string value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(TimeSpan value) => new PrimitiveParameter(value); + public static implicit operator PrimitiveParameter(DateTime value) => new PrimitiveParameter(value); + } +} diff --git a/TS3Client/Properties/AssemblyInfo.cs b/TS3Client/Properties/AssemblyInfo.cs new file mode 100644 index 00000000..71ee959a --- /dev/null +++ b/TS3Client/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("TS3Client")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("TS3Client")] +[assembly: AssemblyCopyright("Copyright © 2016")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("0eb99e9d-87e5-4534-a100-55d231c2b6a6")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/TS3Client/Query/TS3QueryClient.cs b/TS3Client/Query/TS3QueryClient.cs new file mode 100644 index 00000000..91d4078d --- /dev/null +++ b/TS3Client/Query/TS3QueryClient.cs @@ -0,0 +1,135 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client.Query +{ + using Messages; + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Net.Sockets; + + public sealed class TS3QueryClient : TS3BaseClient + { + private readonly TcpClient tcpClient; + private NetworkStream tcpStream; + private StreamReader tcpReader; + private StreamWriter tcpWriter; + + public override ClientType ClientType => ClientType.Query; + + public TS3QueryClient(EventDispatchType dispatcher) : base(dispatcher) + { + tcpClient = new TcpClient(); + } + + protected override void ConnectInternal(ConnectionData conData) + { + try { tcpClient.Connect(conData.Hostname, conData.Port); } + catch (SocketException ex) { throw new TS3CommandException(new CommandError(), ex); } + + tcpStream = tcpClient.GetStream(); + tcpReader = new StreamReader(tcpStream, Util.Encoder); + tcpWriter = new StreamWriter(tcpStream, Util.Encoder) { NewLine = "\n" }; + + for (int i = 0; i < 3; i++) + tcpReader.ReadLine(); + + ConnectDone(); + } + + protected override void DisconnectInternal() + { + tcpWriter?.WriteLine("quit"); + tcpWriter?.Flush(); + tcpClient.Close(); + } + + protected override void NetworkLoop() + { + while (true) + { + string line; + try { line = tcpReader.ReadLine(); } + catch (IOException) { line = null; } + if (line == null) break; + if (string.IsNullOrWhiteSpace(line)) continue; + + var message = line.Trim(); + ProcessCommand(message); + } + Status = TS3ClientStatus.Disconnected; + } + + protected override IEnumerable SendCommand(TS3Command com, Type targetType) // Synchronous + { + using (WaitBlock wb = new WaitBlock(targetType)) + { + lock (LockObj) + { + RequestQueue.Enqueue(wb); + SendRaw(com.ToString()); + } + + return wb.WaitForMessage(); + } + } + + private void SendRaw(string data) + { + tcpWriter.WriteLine(data); + tcpWriter.Flush(); + } + + #region QUERY SPECIFIC COMMANDS + + public void RegisterNotification(MessageTarget target, int channel) => RegisterNotification(target.GetQueryString(), channel); + public void RegisterNotification(RequestTarget target, int channel) => RegisterNotification(target.GetQueryString(), channel); + private void RegisterNotification(string target, int channel) + { + var ev = new CommandParameter("event", target.ToLowerInvariant()); + if (target == "channel") + Send("servernotifyregister", ev, new CommandParameter("id", channel)); + else + Send("servernotifyregister", ev); + } + + public void Login(string username, string password) + => Send("login", + new CommandParameter("client_login_name", username), + new CommandParameter("client_login_password", password)); + public void UseServer(int svrId) + => Send("use", + new CommandParameter("sid", svrId)); + + #endregion + + public override void Dispose() + { + base.Dispose(); + + lock (LockObj) + { + tcpWriter?.Dispose(); + tcpWriter = null; + + tcpReader?.Dispose(); + tcpReader = null; + } + } + } +} \ No newline at end of file diff --git a/TS3Client/TS3BaseClient.cs b/TS3Client/TS3BaseClient.cs new file mode 100644 index 00000000..2b0274a3 --- /dev/null +++ b/TS3Client/TS3BaseClient.cs @@ -0,0 +1,294 @@ +using System.Resources; + +namespace TS3Client +{ + using Messages; + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.Linq; + + public abstract class TS3BaseClient : IDisposable + { + /// This object needs to be locked when one of these situations applies: + /// The connection status needs to be changed. + /// An internal message queue is accessed. + protected readonly object LockObj = new object(); + private bool eventLoopRunning; + private string cmdLineBuffer; + private IEventDispatcher eventDispatcher; + protected TS3ClientStatus Status; + internal readonly Queue RequestQueue; + + public delegate void NotifyEventHandler(object sender, IEnumerable e) where TEventArgs : INotification; + // EVENTS + public event NotifyEventHandler OnTextMessageReceived; + public event NotifyEventHandler OnClientEnterView; + public event NotifyEventHandler OnClientLeftView; + public event EventHandler OnConnected; + + + public abstract ClientType ClientType { get; } + public bool IsConnected => Status == TS3ClientStatus.Connected; + public ConnectionData ConnectionData { get; private set; } + + protected TS3BaseClient(EventDispatchType dispatcher) + { + Status = TS3ClientStatus.Disconnected; + eventLoopRunning = false; + RequestQueue = new Queue(); + + switch (dispatcher) + { + case EventDispatchType.None: eventDispatcher = new NoEventDispatcher(); break; + case EventDispatchType.CurrentThread: eventDispatcher = new CurrentThreadEventDisptcher(); break; + case EventDispatchType.DoubleThread: eventDispatcher = new DoubleThreadEventDispatcher(); break; + case EventDispatchType.AutoThreadPooled: throw new NotSupportedException(); //break; + case EventDispatchType.NewThreadEach: throw new NotSupportedException(); //break; + default: throw new NotSupportedException(); + } + } + + public void Connect(ConnectionData conData) + { + if (string.IsNullOrWhiteSpace(conData.Hostname)) throw new ArgumentNullException(nameof(conData.Hostname)); + if (conData.Port <= 0) throw new ArgumentOutOfRangeException(nameof(conData.Port)); + + if (IsConnected) + Disconnect(); + + lock (LockObj) + { + Status = TS3ClientStatus.Connecting; + ConnectionData = conData; + ConnectInternal(conData); + + eventDispatcher.Init(NetworkLoop); + Status = TS3ClientStatus.Connected; + } + } + protected abstract void ConnectInternal(ConnectionData conData); + public void Disconnect() + { + lock (LockObj) + { + if (IsConnected) + { + Status = TS3ClientStatus.Quitting; + OnTextMessageReceived = null; + OnClientEnterView = null; + OnClientLeftView = null; + DisconnectInternal(); + } + } + } + protected abstract void DisconnectInternal(); + protected void ConnectDone() => OnConnected?.Invoke(this, new EventArgs()); + + #region NETWORK RECEIVE AND DESERIALIZE + + /// Use this method to start the event dispatcher. + /// Please keep in mind that this call might be blocking or non-blocking depending on the dispatch-method. + /// and will enter a loop and block the calling thread. + /// Any other method will start special subroutines and return to the caller. + public void EnterEventLoop() + { + if (!eventLoopRunning) + { + eventLoopRunning = true; + eventDispatcher.EnterEventLoop(); + } + else throw new InvalidOperationException("EventLoop can only be run once until disposed."); + } + + protected abstract void NetworkLoop(); + + /// + /// True if the command was processed, false otherwise. + protected void ProcessCommand(string message) + { + if (message.StartsWith("notify", StringComparison.Ordinal)) + { + var notification = CommandDeserializer.GenerateNotification(message); + InvokeEvent(notification.Item1, notification.Item2); + } + if (message.StartsWith("error ", StringComparison.Ordinal)) + { + // we (hopefully) only need to lock here for the dequeue + lock (LockObj) + { + if (!(Status == TS3ClientStatus.Connected || Status == TS3ClientStatus.Connecting)) return; + + var errorStatus = CommandDeserializer.GenerateErrorStatus(message); + if (!errorStatus.Ok) + RequestQueue.Dequeue().SetAnswer(errorStatus); + else + { + var peek = RequestQueue.Any() ? RequestQueue.Peek() : null; + var response = CommandDeserializer.GenerateResponse(cmdLineBuffer, peek?.AnswerType); + cmdLineBuffer = null; + + RequestQueue.Dequeue().SetAnswer(errorStatus, response); + } + } + } + else + { + cmdLineBuffer = message; + } + } + + protected void InvokeEvent(IEnumerable notification, NotificationType notifyType) + { + switch (notifyType) + { + case NotificationType.ChannelCreated: break; + case NotificationType.ChannelDeleted: break; + case NotificationType.ChannelChanged: break; + case NotificationType.ChannelEdited: break; + case NotificationType.ChannelMoved: break; + case NotificationType.ChannelPasswordChanged: break; + case NotificationType.ClientEnterView: eventDispatcher.Invoke(() => OnClientEnterView?.Invoke(this, notification.Cast())); break; + case NotificationType.ClientLeftView: eventDispatcher.Invoke(() => OnClientLeftView?.Invoke(this, notification.Cast())); break; + case NotificationType.ClientMoved: break; + case NotificationType.ServerEdited: break; + case NotificationType.TextMessage: eventDispatcher.Invoke(() => OnTextMessageReceived?.Invoke(this, notification.Cast())); break; + case NotificationType.TokenUsed: break; + // full client events + case NotificationType.InitIvExpand: eventDispatcher.Invoke(() => ProcessInitIvExpand((InitIvExpand)notification.FirstOrDefault())); break; + case NotificationType.InitServer: eventDispatcher.Invoke(() => ProcessInitServer((InitServer)notification.FirstOrDefault())); break; + case NotificationType.ChannelList: break; + case NotificationType.ChannelListFinished: break; + case NotificationType.ClientNeededPermissions: break; + case NotificationType.ClientChannelGroupChanged: break; + case NotificationType.ClientServerGroupAdded: break; + case NotificationType.ConnectionInfoRequest: break; + case NotificationType.ChannelSubscribed: break; + case NotificationType.ChannelUnsubscribed: break; + case NotificationType.ClientChatComposing: break; + // special + case NotificationType.Unknown: Debug.WriteLine("Unknown notification passed!"); break; + default: throw new InvalidOperationException(); + } + } + + protected virtual void ProcessInitIvExpand(InitIvExpand initIvExpand) { } + protected virtual void ProcessInitServer(InitServer initServer) { } + + #endregion + + #region NETWORK SEND + + [DebuggerStepThrough] + public IEnumerable Send(string command) + => SendCommand(new TS3Command(command), null).Cast(); + + [DebuggerStepThrough] + public IEnumerable Send(string command, params CommandParameter[] parameter) + => SendCommand(new TS3Command(command, parameter.ToList()), null).Cast(); + + [DebuggerStepThrough] + public IEnumerable Send(string command, CommandParameter[] parameter, params CommandOption[] options) + => SendCommand(new TS3Command(command, parameter.ToList(), options.ToList()), null).Cast(); + + [DebuggerStepThrough] + public IEnumerable Send(string command) where T : IResponse + => SendCommand(new TS3Command(command), typeof(T)).Cast(); + + [DebuggerStepThrough] + public IEnumerable Send(string command, params CommandParameter[] parameter) where T : IResponse + => Send(command, parameter.ToList()); + + [DebuggerStepThrough] + public IEnumerable Send(string command, List parameter) where T : IResponse + => SendCommand(new TS3Command(command, parameter), typeof(T)).Cast(); + + [DebuggerStepThrough] + public IEnumerable Send(string command, CommandParameter[] parameter, params CommandOption[] options) where T : IResponse + => SendCommand(new TS3Command(command, parameter.ToList(), options.ToList()), typeof(T)).Cast(); + + [DebuggerStepThrough] + public IEnumerable Send(string command, List parameter, params CommandOption[] options) where T : IResponse + => SendCommand(new TS3Command(command, parameter.ToList(), options.ToList()), typeof(T)).Cast(); + + [DebuggerStepThrough] + protected void SendNoResponsed(TS3Command command) + { + command.ExpectResponse = false; + SendCommand(command, null); + } + + protected abstract IEnumerable SendCommand(TS3Command com, Type targetType); + + #endregion + + #region UNIVERSAL COMMANDS + public void ChangeName(string newName) + => Send("clientupdate", + new CommandParameter("client_nickname", newName)); + public void ChangeDescription(string newDescription, ClientData client) + => Send("clientdbedit", + new CommandParameter("cldbid", client.DatabaseId), + new CommandParameter("client_description", newDescription)); + public WhoAmI WhoAmI() // Q ? + => Send("whoami").FirstOrDefault(); + public void SendMessage(string message, ClientData client) + => SendMessage(MessageTarget.Private, client.ClientId, message); + public void SendMessage(string message, ChannelData channel) + => SendMessage(MessageTarget.Channel, (ulong)channel.Id, message); + public void SendMessage(string message, ServerData server) + => SendMessage(MessageTarget.Server, server.VirtualServerId, message); + public void SendMessage(MessageTarget target, ulong id, string message) + => Send("sendtextmessage", + new CommandParameter("targetmode", (ulong)target), + new CommandParameter("target", id), + new CommandParameter("msg", message)); + public void SendGlobalMessage(string message) + => Send("gm", + new CommandParameter("msg", message)); + public void KickClientFromServer(ushort[] clientIds) + => KickClient(clientIds, RequestTarget.Server); + public void KickClientFromChannel(ushort[] clientIds) + => KickClient(clientIds, RequestTarget.Channel); + public void KickClient(ushort[] clientIds, RequestTarget target) + => Send("clientkick", + new CommandParameter("reasonid", (int)target), + CommandBinder.NewBind("clid", clientIds)); + public IEnumerable ClientList() + => ClientList(0); + public IEnumerable ClientList(ClientListOptions options) => Send("clientlist", + TS3Command.NoParameter, options); + public IEnumerable ServerGroupsOfClientDbId(ClientData client) + => ServerGroupsOfClientDbId(client.DatabaseId); + public IEnumerable ServerGroupsOfClientDbId(ulong clDbId) + => Send("servergroupsbyclientid", new CommandParameter("cldbid", clDbId)); + public ClientDbData ClientDbInfo(ClientData client) + => ClientDbInfo(client.DatabaseId); + public ClientDbData ClientDbInfo(ulong clDbId) + => Send("clientdbinfo", new CommandParameter("cldbid", clDbId)).FirstOrDefault(); + + #endregion + + protected virtual void Reset() + { + cmdLineBuffer = null; + RequestQueue.Clear(); + } + + public virtual void Dispose() + { + Disconnect(); + + eventDispatcher?.Dispose(); + eventDispatcher = null; + } + + protected enum TS3ClientStatus + { + Disconnected, + Connecting, + Connected, + Quitting, + } + } +} diff --git a/TS3Client/TS3Client.csproj b/TS3Client/TS3Client.csproj new file mode 100644 index 00000000..98e8e8b7 --- /dev/null +++ b/TS3Client/TS3Client.csproj @@ -0,0 +1,104 @@ + + + + + Debug + AnyCPU + {0EB99E9D-87E5-4534-A100-55D231C2B6A6} + Exe + Properties + TS3Client + TS3Client + v4.5 + 512 + + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + false + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + + + + ..\packages\BouncyCastle.1.8.1\lib\BouncyCastle.Crypto.dll + True + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/TS3Client/TS3Command.cs b/TS3Client/TS3Command.cs new file mode 100644 index 00000000..04d0c388 --- /dev/null +++ b/TS3Client/TS3Command.cs @@ -0,0 +1,52 @@ +namespace TS3Client +{ + using System; + using System.Collections.Generic; + using System.Text; + using System.Text.RegularExpressions; + + public class TS3Command + { + private static readonly Regex CommandMatch = new Regex(@"[a-z0-9_]+", RegexOptions.Compiled | RegexOptions.CultureInvariant | RegexOptions.ECMAScript); + public static List NoParameter => new List(); + public static List NoOptions => new List(); + + public bool ExpectResponse { get; set; } + public string Command { get; private set; } + private List parameter; + private List options; + + public TS3Command(string command) : this(command, NoParameter) { } + public TS3Command(string command, List parameter) : this(command, parameter, NoOptions) { } + public TS3Command(string command, List parameter, List options) + { + ExpectResponse = true; + this.Command = command; + this.parameter = parameter; + this.options = options; + } + + public void AppendParameter(CommandParameter addParameter) => parameter.Add(addParameter); + public void AppendOption(CommandOption addOption) => options.Add(addOption); + + public override string ToString() => BuildToString(Command, parameter, options); + + public static string BuildToString(string command, IEnumerable parameter, IEnumerable options) + { + if (string.IsNullOrWhiteSpace(command)) + throw new ArgumentNullException(nameof(command)); + if (!CommandMatch.IsMatch(command)) + throw new ArgumentException("Invalid command characters", nameof(command)); + + StringBuilder strb = new StringBuilder(TS3String.Escape(command)); + + foreach (var param in parameter) + strb.Append(' ').Append(param.QueryString); + + foreach (var option in options) + strb.Append(option.Value); + + return strb.ToString(); + } + } +} diff --git a/TS3Client/TS3CommandException.cs b/TS3Client/TS3CommandException.cs new file mode 100644 index 00000000..14f9018d --- /dev/null +++ b/TS3Client/TS3CommandException.cs @@ -0,0 +1,29 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + using System; + + [Serializable] + public class TS3CommandException : Exception + { + public CommandError ErrorStatus { get; private set; } + + internal TS3CommandException(CommandError message) : base(message.ErrorFormat()) { ErrorStatus = message; } + internal TS3CommandException(CommandError message, Exception inner) : base(message.ErrorFormat(), inner) { ErrorStatus = message; } + } +} diff --git a/TS3AudioBot/TS3Query/QueryStringAttribute.cs b/TS3Client/TS3SerializableAttribute.cs similarity index 88% rename from TS3AudioBot/TS3Query/QueryStringAttribute.cs rename to TS3Client/TS3SerializableAttribute.cs index 8f77d598..b2c831e4 100644 --- a/TS3AudioBot/TS3Query/QueryStringAttribute.cs +++ b/TS3Client/TS3SerializableAttribute.cs @@ -14,16 +14,16 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query +namespace TS3Client { using System; [AttributeUsage(AttributeTargets.Field, Inherited = false, AllowMultiple = false)] - sealed class QueryStringAttribute : Attribute + sealed class TS3SerializableAttribute : Attribute { public string QueryString { get; } - public QueryStringAttribute(string queryString) + public TS3SerializableAttribute(string queryString) { QueryString = queryString; } diff --git a/TS3AudioBot/TS3Query/TS3QueryTools.cs b/TS3Client/TS3String.cs similarity index 67% rename from TS3AudioBot/TS3Query/TS3QueryTools.cs rename to TS3Client/TS3String.cs index 4160e0b4..6121f19a 100644 --- a/TS3AudioBot/TS3Query/TS3QueryTools.cs +++ b/TS3Client/TS3String.cs @@ -14,12 +14,13 @@ // You should have received a copy of the GNU Affero General Public License // along with this program. If not, see . -namespace TS3Query +namespace TS3Client { using System; + using System.Linq; using System.Text; - public static class TS3QueryTools + public static class TS3String { public static string Escape(string stringToEscape) { @@ -47,26 +48,36 @@ public static string Unescape(string stringToUnescape) if (++i >= stringToUnescape.Length) throw new FormatException(); switch (stringToUnescape[i]) { - case 'v': strb.Append('\v'); break; // Vertical Tab - case 't': strb.Append('\t'); break; // Horizontal Tab - case 'r': strb.Append('\r'); break; // Carriage Return - case 'n': strb.Append('\n'); break; // Newline - case 'f': strb.Append('\f'); break; // Formfeed - case 'p': strb.Append('|'); break; // Pipe - case 's': strb.Append(' '); break; // Whitespace - case '/': strb.Append('/'); break; // Slash - case '\\': strb.Append('\\'); break; // Backslash - default: throw new FormatException(); + case 'v': strb.Append('\v'); break; // Vertical Tab + case 't': strb.Append('\t'); break; // Horizontal Tab + case 'r': strb.Append('\r'); break; // Carriage Return + case 'n': strb.Append('\n'); break; // Newline + case 'f': strb.Append('\f'); break; // Formfeed + case 'p': strb.Append('|'); break; // Pipe + case 's': strb.Append(' '); break; // Whitespace + case '/': strb.Append('/'); break; // Slash + case '\\': strb.Append('\\'); break; // Backslash + default: throw new FormatException(); } } else strb.Append(c); } return strb.ToString(); } - } - static class Helper - { - public static T Init(ref T fld) where T : new() => fld = new T(); + public static int TokenLength(string str) => str.Length + str.Count(IsDoubleChar); + + public static bool IsDoubleChar(char c) + { + return c == '\\' || + c == '/' || + c == ' ' || + c == '|' || + c == '\f' || + c == '\n' || + c == '\r' || + c == '\t' || + c == '\v'; + } } } diff --git a/TS3Client/Util.cs b/TS3Client/Util.cs new file mode 100644 index 00000000..2cba3ed4 --- /dev/null +++ b/TS3Client/Util.cs @@ -0,0 +1,48 @@ +using System; + +namespace TS3Client +{ + using System.Collections.Generic; + using System.Linq; + using System.Text; + + internal static class Util + { + public static IEnumerable Slice(this IList arr, int from) => Slice(arr, from, arr.Count - from); + public static IEnumerable Slice(this IEnumerable arr, int from, int len) => arr.Skip(from).Take(len); + + public static void Init(ref T fld) where T : new() => fld = new T(); + + public static Encoding Encoder { get; } = new UTF8Encoding(false); + + public static IEnumerable SplitInParts(this string s, int partLength) + { + if (s == null) + throw new ArgumentNullException(nameof(s)); + if (partLength <= 0) + throw new ArgumentException("Part length has to be positive.", nameof(partLength)); + + for (var i = 0; i < s.Length; i += partLength) + yield return s.Substring(i, Math.Min(partLength, s.Length - i)); + } + + public static byte[] Hex2Byte(this string str) + { + return (str.Contains(' ') + ? str.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries) + : str.SplitInParts(2)) + .Select(x => Convert.ToByte(x, 16)) + .ToArray(); + } + + public static string Byte2Hex(this IList ba) => Byte2Hex(ba, 0, ba.Count); + public static string Byte2Hex(this IList ba, int index, int length) + { + StringBuilder hex = new StringBuilder(length * 2); + int max = index + length; + for (int i = index; i < max; i++) + hex.AppendFormat("{0:x2} ", ba[i]); + return hex.ToString(); + } + } +} diff --git a/TS3Client/WaitBlock.cs b/TS3Client/WaitBlock.cs new file mode 100644 index 00000000..1b027de1 --- /dev/null +++ b/TS3Client/WaitBlock.cs @@ -0,0 +1,68 @@ +// TS3AudioBot - An advanced Musicbot for Teamspeak 3 +// Copyright (C) 2016 TS3AudioBot contributors +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +namespace TS3Client +{ + using Messages; + using System; + using System.Collections.Generic; + using System.Threading; + + internal class WaitBlock : IDisposable + { + private AutoResetEvent waiter = new AutoResetEvent(false); + private IEnumerable answer = null; + private CommandError errorStatus = null; + public Type AnswerType { get; } + + public WaitBlock(Type answerType) + { + AnswerType = answerType; + } + + public IEnumerable WaitForMessage() + { + waiter.WaitOne(); + if (!errorStatus.Ok) + throw new TS3CommandException(errorStatus); + return answer; + } + + public void SetAnswer(CommandError error, IEnumerable answer) + { + this.answer = answer; + SetAnswer(error); + } + + public void SetAnswer(CommandError error) + { + if (error == null) + throw new ArgumentNullException(nameof(error)); + errorStatus = error; + waiter.Set(); + } + + public void Dispose() + { + if (waiter != null) + { + waiter.Set(); + waiter.Dispose(); + waiter = null; + } + } + } +} diff --git a/TS3Client/packages.config b/TS3Client/packages.config new file mode 100644 index 00000000..730a601e --- /dev/null +++ b/TS3Client/packages.config @@ -0,0 +1,4 @@ + + + + \ No newline at end of file