マルチコメントビューアーを他の配信サイトに対応した話

技術

背景

「B-LIVE」というライブ配信サイトに、コメントビューアーが欲しいという要望があり、開発してみることに。
1から作り上げるのはしんどいので、ソースコードが公開されているコメントビューアーを改造する形にした。
選ばれたのはマルチコメントビューアーでした。

マルチコメントビューアーを選んだ理由
・ソースコードが公開されている
・利用ユーザーが多そう
・古代からある
・対応サイトも結構多い

結論から言うと、対応は出来た。
・開発時間:40時間
・ソースコード:こちらからダウンロードできます。
・問題点:開発元のRyu氏が公式に公開してくれるのか?

ソースコード解析

配布元

MultiCommentViewer

GitHub - CommentViewerCollection/MultiCommentViewer: いろんな配信サイトのコメントを表示できるコメビュです
いろんな配信サイトのコメントを表示できるコメビュです. Contribute to CommentViewerCollection/MultiCommentViewer development by creating an account ...

OPENREC のプラグインの解析

サイトごとの設定ファイルが複数あるが、とりあえずOPENRECのソースコードを解析していく

ファイル構成

まずは全体のファイル構成
※主要そうな部分のみ抜粋

MultiCommentViewer/
 │
 ├MultiCommentViewer/
 │ ├ViewModels/
 │  ├ConnectionViewModel.cs
 │ ├Views/
 │  ├MainWindow.xaml
 │
 ├OpenrecIF/
 │ ├Message.cs/
 │ └OpenrecIF.csproj
 │
 ├OpenrecSitePlugin
 │ ├API.cs
 │ ├CommentPostPanel.xaml
 │ ├CommentProvider.cs
 │ ├IOpenrecWebsocket.cs
 │ ├OpenrecOptionsPanel.xaml
 │ ├OpenrecSitePlugin.csproj
 │ ├OpenrecWebsocket.cs
 │ ├Packet.cs
 │ ├Tools.cs
 │ └Websocket.cs
 │
 ├OpenrecSitePluginTests
 │
 ├MultiCommentViewer.sln

MultiCommentViewer.sln


Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31825.309
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MultiCommentViewer", "MultiCommentViewer\MultiCommentViewer.csproj", "{1CAA4971-6CB9-4FB5-AC5E-BFC6BED83C87}"
	ProjectSection(ProjectDependencies) = postProject
		{88B68D39-41A4-4C10-B942-0F3BE976D7A3} = {88B68D39-41A4-4C10-B942-0F3BE976D7A3}
		{A39D3546-FB5F-41FC-AAE0-2AC1B5C61ECF} = {A39D3546-FB5F-41FC-AAE0-2AC1B5C61ECF}
		{5C5D8468-1FA6-44DB-B196-003164FBB91A} = {5C5D8468-1FA6-44DB-B196-003164FBB91A}
		{EF2FABAC-9D97-4FFC-8FEE-B4FBDEA0CF34} = {EF2FABAC-9D97-4FFC-8FEE-B4FBDEA0CF34}
		{A04C3DC9-78CB-4DB2-B8BA-0D462A64314F} = {A04C3DC9-78CB-4DB2-B8BA-0D462A64314F}
		{4098E8D6-7954-4B24-96C9-1CFC6BC8A654} = {4098E8D6-7954-4B24-96C9-1CFC6BC8A654}
		{13A909DD-3791-4539-9C04-F72D9E755DCF} = {13A909DD-3791-4539-9C04-F72D9E755DCF}
		{8C6AB9F7-E1C9-4B2A-9F17-E51E32EA43CC} = {8C6AB9F7-E1C9-4B2A-9F17-E51E32EA43CC}
	EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenrecSitePlugin", "OpenrecSitePlugin\OpenrecSitePlugin.csproj", "{B56F2F0D-197A-4A29-A29A-1F92ECD81902}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenrecSitePluginTests", "OpenrecSitePluginTests\OpenrecSitePluginTests.csproj", "{8C6AB9F7-E1C9-4B2A-9F17-E51E32EA43CC}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "OpenrecIF", "OpenrecIF\OpenrecIF.csproj", "{02A55625-E735-4909-A470-E8405EEE1A32}"
EndProject
Global
	GlobalSection(SolutionConfigurationPlatforms) = preSolution
		Alpha|Any CPU = Alpha|Any CPU
		Beta|Any CPU = Beta|Any CPU
		Debug|Any CPU = Debug|Any CPU
		Release|Any CPU = Release|Any CPU
	EndGlobalSection
EndGlobal

OpenrecIF.csproj

<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>net462</TargetFramework>
    <LangVersion>8.0</LangVersion>
    <Configurations>Release;Beta;Alpha;Debug</Configurations>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
    <OutputPath>bin\Release\</OutputPath>
    <DefineConstants>TRACE</DefineConstants>
    <WarningLevel>4</WarningLevel>
    <DebugType>pdbonly</DebugType>
    <Optimize>true</Optimize>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Beta|AnyCPU'">
    <OutputPath>bin\Beta\</OutputPath>
    <DefineConstants>TRACE;BETA</DefineConstants>
    <WarningLevel>4</WarningLevel>
    <Optimize>true</Optimize>
    <DebugType>pdbonly</DebugType>
    <PlatformTarget>AnyCPU</PlatformTarget>
    <ErrorReport>prompt</ErrorReport>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Alpha|AnyCPU'">
    <DefineConstants>TRACE;DEBUG;ALPHA</DefineConstants>
    <WarningLevel>4</WarningLevel>
    <DebugType>full</DebugType>
    <DebugSymbols>true</DebugSymbols>
    <Optimize>false</Optimize>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
    <DefineConstants>DEBUG;TRACE</DefineConstants>
    <WarningLevel>4</WarningLevel>
    <DebugType>full</DebugType>
    <DebugSymbols>true</DebugSymbols>
  </PropertyGroup>
  <ItemGroup>
    <ProjectReference Include="..\ISitePlugin\SitePlugin.csproj" />
  </ItemGroup>
</Project>

OpenrecSitePlugin.csproj

<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
  <PropertyGroup>
    <UseWPF>true</UseWPF>
    <TargetFramework>net462</TargetFramework>
    <LangVersion>8.0</LangVersion>
    <Configurations>Release;Beta;Alpha;Debug</Configurations>
    <GenerateResourceUsePreserializedResources>true</GenerateResourceUsePreserializedResources>
  </PropertyGroup>
  <PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Release|AnyCPU' ">
    <OutputPath>bin\Release\</OutputPath>
    <DefineConstants>TRACE</DefineConstants>
    <WarningLevel>4</WarningLevel>
    <DebugType>pdbonly</DebugType>
    <Optimize>true</Optimize>
    <ErrorReport>prompt</ErrorReport>
    <WarningLevel>4</WarningLevel>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)|$(Platform)' == 'Beta|AnyCPU'">
    <OutputPath>bin\Beta\</OutputPath>
    <DefineConstants>TRACE;BETA</DefineConstants>
    <WarningLevel>4</WarningLevel>
    <Optimize>true</Optimize>
    <DebugType>pdbonly</DebugType>
    <PlatformTarget>AnyCPU</PlatformTarget>
    <ErrorReport>prompt</ErrorReport>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Alpha|AnyCPU'">
    <DefineConstants>TRACE;DEBUG;ALPHA</DefineConstants>
    <WarningLevel>4</WarningLevel>
    <DebugType>full</DebugType>
    <DebugSymbols>true</DebugSymbols>
    <Optimize>false</Optimize>
  </PropertyGroup>
  <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
    <DefineConstants>DEBUG;TRACE</DefineConstants>
    <WarningLevel>4</WarningLevel>
    <DebugType>full</DebugType>
    <DebugSymbols>true</DebugSymbols>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Extended.Wpf.Toolkit" Version="4.0.2" />
    <PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
    <PackageReference Include="System.Resources.Extensions" Version="4.6.0" />
    <PackageReference Include="System.ServiceModel.Primitives" Version="4.8.1" />
    <PackageReference Include="System.ValueTuple" Version="4.5.0" />
  </ItemGroup>
  <ItemGroup>
    <ProjectReference Include="..\BrowserCookieInterfaces\BrowserCookieInterfaces.csproj" />
    <ProjectReference Include="..\Common\Common.csproj" />
    <ProjectReference Include="..\ISitePlugin\SitePlugin.csproj" />
    <ProjectReference Include="..\OpenrecIF\OpenrecIF.csproj" />
    <ProjectReference Include="..\SitePluginCommon\SitePluginCommon.csproj" />
  </ItemGroup>
  <ItemGroup>
    <Reference Include="Microsoft.CSharp" />
    <Reference Include="PresentationCore" />
    <Reference Include="PresentationFramework" />
    <Reference Include="System.Net.Http" />
    <Reference Include="System.Web" />
    <Reference Include="System.Windows" />
    <Reference Include="System.Xaml" />
    <Reference Include="WindowsBase" />
  </ItemGroup>
  <ItemGroup>
    <Compile Update="Properties\Resources.Designer.cs">
      <DesignTime>True</DesignTime>
      <AutoGen>True</AutoGen>
      <DependentUpon>Resources.resx</DependentUpon>
    </Compile>
  </ItemGroup>

  <ItemGroup>
    <EmbeddedResource Update="Properties\Resources.resx">
      <Generator>ResXFileCodeGenerator</Generator>
      <LastGenOutput>Resources.Designer.cs</LastGenOutput>
    </EmbeddedResource>
  </ItemGroup>
</Project>

MainWindow.xaml

<Window x:Class="MultiCommentViewer.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:MultiCommentViewer"
        xmlns:c="clr-namespace:Common;assembly=Common"
        xmlns:w="clr-namespace:Common.Wpf;assembly=Common"
        xmlns:i="clr-namespace:System.Windows.Interactivity;assembly=System.Windows.Interactivity"
        xmlns:command="clr-namespace:GalaSoft.MvvmLight.Command;assembly=GalaSoft.MvvmLight.Platform"
        xmlns:xctk="http://schemas.xceed.com/wpf/xaml/toolkit"
        mc:Ignorable="d"
        d:DataContext="{d:DesignInstance local:MainViewModel, IsDesignTimeCreatable=True}"
        d:DesignWidth="1000"
        Title="{Binding Title}" Topmost="{Binding Topmost, Mode=TwoWay}"
        Height="{Binding MainViewHeight, Mode=TwoWay}" Width="{Binding MainViewWidth, Mode=TwoWay}"
        Left="{Binding MainViewLeft, Mode=TwoWay}" Top="{Binding MainViewTop, Mode=TwoWay}"
        >
    <Grid>
                    <DataGridTemplateColumn
                        Header="サイト"
                        DisplayIndex="{Binding DataContext.ConnectionsViewSiteDisplayIndex, Mode=TwoWay, Source={x:Reference dummyElement}, FallbackValue=1}"
                        Width="{Binding DataContext.ConnectionsViewSiteWidth, Mode=TwoWay, Source={x:Reference dummyElement}, Converter={StaticResource dataGridLengthConverter}}"
                        Visibility="{Binding DataContext.IsShowConnectionsViewSite, Source={x:Reference dummyElement}, Converter={StaticResource booleanToVisibilityConverter}}"
                        >
                        <DataGridTemplateColumn.CellTemplate>
                            <DataTemplate>
                                <ComboBox BorderBrush="Transparent" Margin="5,0" ItemsSource="{Binding Sites}" SelectedValue="{Binding SelectedSite, UpdateSourceTrigger=PropertyChanged}" DisplayMemberPath="DisplayName" IsEnabled="{Binding CanConnect}" />
                            </DataTemplate>
                        </DataGridTemplateColumn.CellTemplate>
                    </DataGridTemplateColumn>
                    <DataGridTemplateColumn
                        Header="名前"
                        DisplayIndex="{Binding DataContext.ConnectionsViewConnectionNameDisplayIndex, Mode=TwoWay, Source={x:Reference dummyElement}, FallbackValue=2}"
                        Width="{Binding DataContext.ConnectionsViewConnectionNameWidth, Mode=TwoWay, Source={x:Reference dummyElement}, Converter={StaticResource dataGridLengthConverter}}"
                        Visibility="{Binding DataContext.IsShowConnectionsViewConnectionName, Source={x:Reference dummyElement}, Converter={StaticResource booleanToVisibilityConverter}}"
                        >
                        <DataGridTemplateColumn.CellTemplate>
                            <DataTemplate>
                                <TextBox BorderBrush="Transparent" Margin="5,0" Text="{Binding Name, UpdateSourceTrigger=LostFocus}" />
                            </DataTemplate>
                        </DataGridTemplateColumn.CellTemplate>
                    </DataGridTemplateColumn>
                    <DataGridTemplateColumn
                        Header="URL,放送ID等"
                        DisplayIndex="{Binding DataContext.ConnectionsViewInputDisplayIndex, Mode=TwoWay, Source={x:Reference dummyElement}, FallbackValue=3}"
                        Width="{Binding DataContext.ConnectionsViewInputWidth, Mode=TwoWay, Source={x:Reference dummyElement}, Converter={StaticResource dataGridLengthConverter}}"
                        Visibility="{Binding DataContext.IsShowConnectionsViewInput, Source={x:Reference dummyElement}, Converter={StaticResource booleanToVisibilityConverter}}"
                        >
                        <DataGridTemplateColumn.CellTemplate>
                            <DataTemplate>
                                <TextBox Margin="5,0" Text="{Binding Input, UpdateSourceTrigger=PropertyChanged}">
                                    <TextBox.ToolTip>
                                        <TextBlock Text="{Binding Input}" />
                                    </TextBox.ToolTip>
                                </TextBox>
                            </DataTemplate>
                        </DataGridTemplateColumn.CellTemplate>
                    </DataGridTemplateColumn>
                    <DataGridTemplateColumn
                        Header="ブラウザ"
                        DisplayIndex="{Binding DataContext.ConnectionsViewBrowserDisplayIndex, Mode=TwoWay, Source={x:Reference dummyElement}, FallbackValue=4}"
                        Width="{Binding DataContext.ConnectionsViewBrowserWidth, Mode=TwoWay, Source={x:Reference dummyElement}, Converter={StaticResource dataGridLengthConverter}}"
                        Visibility="{Binding DataContext.IsShowConnectionsViewBrowser, Source={x:Reference dummyElement}, Converter={StaticResource booleanToVisibilityConverter}}"
                        >
                        <DataGridTemplateColumn.CellTemplate>
                            <DataTemplate>
                                <ComboBox Margin="5,0" ItemsSource="{Binding Browsers}" SelectedValue="{Binding SelectedBrowser, UpdateSourceTrigger=PropertyChanged}" DisplayMemberPath="DisplayName" IsEnabled="{Binding CanConnect}" />
                            </DataTemplate>
                        </DataGridTemplateColumn.CellTemplate>
                    </DataGridTemplateColumn>
                    <DataGridTemplateColumn
                        Header="接続"
                        DisplayIndex="{Binding DataContext.ConnectionsViewConnectionDisplayIndex, Mode=TwoWay, Source={x:Reference dummyElement}, FallbackValue=5}"
                        Width="{Binding DataContext.ConnectionsViewConnectionWidth, Mode=TwoWay, Source={x:Reference dummyElement}, Converter={StaticResource dataGridLengthConverter}}"
                        Visibility="{Binding DataContext.IsShowConnectionsViewConnection, Source={x:Reference dummyElement}, Converter={StaticResource booleanToVisibilityConverter}}"
                        >
                        <DataGridTemplateColumn.CellTemplate>
                            <DataTemplate>
                                <Button Margin="5,0" Content="接続" IsEnabled="{Binding CanConnect}" Command="{Binding ConnectCommand}" />
                            </DataTemplate>
                        </DataGridTemplateColumn.CellTemplate>
                    </DataGridTemplateColumn>
                    <DataGridTemplateColumn
                        Header="切断"
                        DisplayIndex="{Binding DataContext.ConnectionsViewDisconnectionDisplayIndex, Mode=TwoWay, Source={x:Reference dummyElement}, FallbackValue=6}"
                        Width="{Binding DataContext.ConnectionsViewDisconnectionWidth, Mode=TwoWay, Source={x:Reference dummyElement}, Converter={StaticResource dataGridLengthConverter}}"
                        Visibility="{Binding DataContext.IsShowConnectionsViewDisconnection, Source={x:Reference dummyElement}, Converter={StaticResource booleanToVisibilityConverter}}"
                        >
                        <DataGridTemplateColumn.CellTemplate>
                            <DataTemplate>
                                <Button Margin="5,0" Content="切断" IsEnabled="{Binding CanDisconnect}" Command="{Binding DisconnectCommand}" />
                            </DataTemplate>
                        </DataGridTemplateColumn.CellTemplate>
                    </DataGridTemplateColumn>
    </Grid>
</Window>

CommentPostPanel.xaml

<UserControl x:Class="OpenrecSitePlugin.CommentPostPanel"
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:OpenrecSitePlugin"
             mc:Ignorable="d" 
             d:DataContext="{d:DesignInstance local:CommentPostPanelViewModel, IsDesignTimeCreatable=True}"
             d:DesignHeight="300" d:DesignWidth="400"
             Height="50" Width="400">
    <Grid>
        <TextBox Text="{Binding Input}" IsEnabled="{Binding CanPostComment}" HorizontalAlignment="Stretch" Height="23" Margin="5,0,45,5" TextWrapping="Wrap" VerticalAlignment="Bottom"/>
        <Button Content="投稿" IsEnabled="{Binding CanPostComment}" HorizontalAlignment="Right" Margin="0,0,5,5" VerticalAlignment="Bottom" Width="35"/>

    </Grid>
</UserControl>

OpenrecOptionsPanel.xaml

<UserControl
             xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
             xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
             xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" 
             xmlns:d="http://schemas.microsoft.com/expression/blend/2008" 
             xmlns:local="clr-namespace:OpenrecSitePlugin"
             xmlns:Common="clr-namespace:Common;assembly=Common" x:Class="OpenrecSitePlugin.OpenrecOptionsPanel"
             mc:Ignorable="d" Height="290" Width="420">
    <Grid>

        <Common:NumericUpDown Value="{Binding StampSize,Mode=TwoWay,UpdateSourceTrigger=PropertyChanged}" Minimum="1" HorizontalAlignment="Left" Height="30" Margin="94,20,0,0" VerticalAlignment="Top" Width="90"/>
        <Label Content="スタンプのサイズ" HorizontalAlignment="Left" Margin="10,22,0,0" VerticalAlignment="Top"/>
        <CheckBox x:Name="checkBoxStampMusic" Content="スタンプが貼られた時に音を鳴らす(現状、waveファイルのみ対応)" IsChecked="{Binding IsPlayStampMusic}" HorizontalAlignment="Left" Margin="10,70,0,0" VerticalAlignment="Top" RenderTransformOrigin="0.352,0.333"/>
        <Button Content="開く" HorizontalAlignment="Left" IsEnabled="{Binding IsChecked, ElementName=checkBoxStampMusic}" Command="{Binding ShowOpenStampMusicSelectorCommand}" Margin="322,91,0,0" VerticalAlignment="Top" Width="53"/>
        <TextBox HorizontalAlignment="Left" IsEnabled="{Binding IsChecked, ElementName=checkBoxStampMusic}" Height="23" Margin="56,91,0,0" TextWrapping="NoWrap" Text="{Binding StampMusicFilePath}" VerticalAlignment="Top" Width="261"/>
        <CheckBox x:Name="checkBoxYellMusic" Content="エールが送られた時に音を鳴らす(現状、waveファイルのみ対応)" IsChecked="{Binding IsPlayYellMusic}" HorizontalAlignment="Left" Margin="10,119,0,0" VerticalAlignment="Top"/>
        <Button Content="開く" HorizontalAlignment="Left" IsEnabled="{Binding IsChecked, ElementName=checkBoxYellMusic}" Command="{Binding ShowOpenYellMusicSelectorCommand}" Margin="322,142,0,0" VerticalAlignment="Top" Width="53" RenderTransformOrigin="0.453,1.4"/>
        <TextBox HorizontalAlignment="Left" IsEnabled="{Binding IsChecked, ElementName=checkBoxYellMusic}"  Height="23" Margin="56,139,0,0" TextWrapping="Wrap" Text="{Binding YellMusicFilePath}" VerticalAlignment="Top" Width="261"/>
        <CheckBox Content="@のあとの文字列を自動的にコテハンとして登録する" IsChecked="{Binding IsAutoSetNickname}" HorizontalAlignment="Left" Margin="10,180,0,0" VerticalAlignment="Top"/>
    </Grid>
</UserControl>

ConnectionViewModel.cs

namespace MultiCommentViewer
{
    public class ConnectionContext
    {
        public ConnectionName ConnectionName { get; set; }
        public ICommentProvider CommentProvider { get; set; }
        public Guid SiteGuid { get; set; }
    }
    public class ConnectionViewModel : ViewModelBase, IConnectionStatus
    {
        public ObservableCollection<SiteViewModel> Sites { get; }
        public ObservableCollection<BrowserViewModel> Browsers { get; }
        private SiteViewModel _selectedSite;
        private ICommentProvider _commentProvider = null;
        private readonly ConnectionName _connectionName;
        public ICommand ConnectCommand { get; }
        public ICommand DisconnectCommand { get; }
        public event EventHandler<SelectedSiteChangedEventArgs> SelectedSiteChanged;

        private ConnectionContext _beforeContext;
        private ConnectionContext _currentContext;
        public SiteViewModel SelectedSite
        {
            get { return _selectedSite; }
            set
            {
                if (_selectedSite == value)
                    return;
                //一番最初は_commentProviderはnull
                var before = _commentProvider;
                if (before != null)
                {
                    Debug.Assert(before.CanConnect, "接続中に変更はできない");
                    before.CanConnectChanged -= CommentProvider_CanConnectChanged;
                    before.CanDisconnectChanged -= CommentProvider_CanDisconnectChanged;
                    before.MessageReceived -= CommentProvider_MessageReceived;
                    before.MetadataUpdated -= CommentProvider_MetadataUpdated;
                    before.Connected -= CommentProvider_Connected;
                }
                _selectedSite = value;
                var nextGuid = _selectedSite.Guid;
                var next = _commentProvider = _sitePluginLoader.CreateCommentProvider(nextGuid);
                next.CanConnectChanged += CommentProvider_CanConnectChanged;
                next.CanDisconnectChanged += CommentProvider_CanDisconnectChanged;
                next.MessageReceived += CommentProvider_MessageReceived;
                next.MetadataUpdated += CommentProvider_MetadataUpdated;
                next.Connected += CommentProvider_Connected;
                UpdateLoggedInInfo();

                System.Windows.Controls.UserControl commentPanel;
                try
                {
                    commentPanel = _sitePluginLoader.GetCommentPostPanel(nextGuid, next);
                }
                catch (Exception ex)
                {
                    _logger.LogException(ex);
                    commentPanel = null;
                }
                CommentPostPanel = commentPanel;

                _beforeContext = _currentContext;
                _currentContext = new ConnectionContext
                {
                    ConnectionName = this.ConnectionName,
                    CommentProvider = next,
                    SiteGuid = nextGuid,
                    //SiteContext = _selectedSite.Site,
                };
                RaisePropertyChanged();
                SelectedSiteChanged?.Invoke(this, new SelectedSiteChangedEventArgs
                {
                    ConnectionName = this.ConnectionName,
                    OldValue = _beforeContext,
                    NewValue = _currentContext
                });
            }
        }

        private async void Connect()
        {
            try
            {
                //接続中は削除できないように選択を外す
                IsSelected = false;
                var input = Input;
                var browser = SelectedBrowser.Browser;
                await _commentProvider.ConnectAsync(input, browser);


            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.Message);
                _logger.LogException(ex);
            }
        }
        public ConnectionViewModel(ConnectionName connectionName, IEnumerable<SiteViewModel> sites, IEnumerable<BrowserViewModel> browsers, ILogger logger, ISitePluginLoader sitePluginLoader, IOptions options)
        {
            _dispatcher = Dispatcher.CurrentDispatcher;
            Guid = Guid.NewGuid();
            _logger = logger;
            _sitePluginLoader = sitePluginLoader;
            _options = options;
            _connectionName = connectionName ?? throw new ArgumentNullException(nameof(connectionName));
            _beforeName = _connectionName.Name;
            _connectionName.PropertyChanged += (s, e) =>
            {
                switch (e.PropertyName)
                {
                    case nameof(_connectionName.Name):
                        var newName = _connectionName.Name;
                        Renamed?.Invoke(this, new RenamedEventArgs(_beforeName, newName));
                        _beforeName = newName;
                        RaisePropertyChanged(nameof(Name));
                        break;
                }
            };
            options.PropertyChanged += (s, e) =>
            {
                switch (e.PropertyName)
                {
                    case nameof(options.IsEnabledSiteConnectionColor):
                        RaisePropertyChanged(nameof(BackColor));
                        RaisePropertyChanged(nameof(ForeColor));
                        break;
                    case nameof(options.SiteConnectionColorType):
                        RaisePropertyChanged(nameof(BackColor));
                        RaisePropertyChanged(nameof(ForeColor));
                        break;
                }
            };

            if (sites == null)
            {
                throw new ArgumentNullException(nameof(sites));
            }
            Sites = new ObservableCollection<SiteViewModel>();
            foreach (var siteVm in sites)
            {
                _siteVmDict.Add(siteVm.Guid, siteVm);
                Sites.Add(siteVm);
            }
            //Sites = new ObservableCollection<SiteViewModel>(sites);
            if (Sites.Count > 0)
            {
                SelectedSite = Sites[0];
            }

            Browsers = new ObservableCollection<BrowserViewModel>(browsers);
            if (Browsers.Count > 0)
            {
                SelectedBrowser = Browsers[0];
            }
            ConnectCommand = new RelayCommand(Connect);
            DisconnectCommand = new RelayCommand(Disconnect);
        }
    }
}

CommentProvider.cs


namespace OpenrecSitePlugin
{
    [Serializable]
    public class InvalidInputException : Exception
    {
        public InvalidInputException() { }
    }
    class CommentProvider : ICommentProvider
    {
        string _liveId;
        Context _context;
        #region ICommentProvider
        #region Events
        public event EventHandler<IMetadata> MetadataUpdated;
        public event EventHandler CanConnectChanged;
        public event EventHandler CanDisconnectChanged;
        public event EventHandler<ConnectedEventArgs> Connected;
        public event EventHandler<IMessageContext> MessageReceived;
        #endregion //Events

        #region CanConnect
        private bool _canConnect;
        public bool CanConnect
        {
            get { return _canConnect; }
            set
            {
                if (_canConnect == value)
                    return;
                _canConnect = value;
                CanConnectChanged?.Invoke(this, EventArgs.Empty);
            }
        }
        protected virtual Task<string> GetLiveId(string input)
        {
            return Tools.GetLiveId(_dataSource, input);
        }
        //protected virtual Extract()
        private async Task ConnectInternalAsync(string input, IBrowserProfile browserProfile)
        {
            if (_ws != null)
            {
                throw new InvalidOperationException("");
            }
            var cookies = GetCookies(browserProfile);
            _cc = CreateCookieContainer(cookies);
            _context = Tools.GetContext(cookies);
            string liveId;
            try
            {
                liveId = await GetLiveId(input);
                _liveId = liveId;
            }
            catch (InvalidInputException ex)
            {
                _logger.LogException(ex, "無効な入力値", $"input={input}");
                SendSystemInfo("無効な入力値です", InfoType.Error);
                AfterDisconnected();
                return;
            }

            var movieContext2 = await GetMovieInfo(liveId);
            var movieId = movieContext2.MovieId;
            if (movieId == 0)
            {
                SendSystemInfo("存在しないURLまたはIDです", InfoType.Error);
                AfterDisconnected();
                return;
            }
            if (movieContext2.OnairStatus == 2)
            {
                SendSystemInfo("この放送は終了しています", InfoType.Error);
                AfterDisconnected();
                return;
            }
            MetadataUpdated?.Invoke(this, new Metadata { Title = movieContext2.Title });

            _startAt = movieContext2.StartedAt.DateTime;
            _500msTimer.Enabled = true;

            var (chats, raw) = await GetChats(movieContext2);
            try
            {
                foreach (var item in chats)
                {
                    var comment = Tools.Parse(item);
                    var commentData = Tools.CreateCommentData(comment, _startAt, _siteOptions);
                    var messageContext = CreateMessageContext(comment, commentData, true);
                    if (messageContext != null)
                    {
                        MessageReceived?.Invoke(this, messageContext);
                    }
                }
            }
            catch (Exception ex)
            {
                _logger.LogException(ex, "", "raw=" + raw);
            }
            foreach (var user in _userStoreManager.GetAllUsers(SiteType.Openrec))
            {
                if (!(user is IUser2 user2)) continue;
                _userDict.AddOrUpdate(user2.UserId, user2, (id, u) => u);
            }
        Reconnect:
            _ws = CreateOpenrecWebsocket();
            _ws.Received += WebSocket_Received;

            var userAgent = GetUserAgent(browserProfile.Type);
            var wsTask = _ws.ReceiveAsync(movieId.ToString(), userAgent, cookies);
            var blackListProvider = CreateBlacklistProvider();
            blackListProvider.Received += BlackListProvider_Received;
            var blackTask = blackListProvider.ReceiveAsync(movieId.ToString(), _context);

            var tasks = new List<Task>
            {
                wsTask,
                blackTask
            };

            while (tasks.Count > 0)
            {
                var t = await Task.WhenAny(tasks);
                if (t == blackTask)
                {
                    try
                    {
                        await blackTask;
                    }
                    catch (Exception ex)
                    {
                        _logger.LogException(ex);
                    }
                    tasks.Remove(blackTask);
                }
                else
                {
                    blackListProvider.Disconnect();
                    try
                    {
                        await blackTask;
                    }
                    catch (Exception ex)
                    {
                        _logger.LogException(ex);
                    }
                    tasks.Remove(blackTask);
                    SendSystemInfo("ブラックリストタスク終了", InfoType.Debug);
                    try
                    {
                        await wsTask;
                    }
                    catch (Exception ex)
                    {
                        _logger.LogException(ex);
                    }
                    tasks.Remove(wsTask);
                    SendSystemInfo("wsタスク終了", InfoType.Debug);
                }
            }
            _ws.Received -= WebSocket_Received;
            blackListProvider.Received -= BlackListProvider_Received;

            //意図的な切断では無い場合、配信がまだ続いているか確認して、配信中だったら再接続する。
            //2019/03/12 heartbeatを送っているのにも関わらずwebsocketが切断されてしまう場合を確認。ブラウザでも配信中に切断されて再接続するのを確認済み。
            if (!_isExpectedDisconnect)
            {
                var movieInfo = await GetMovieInfo(liveId);
                if (movieInfo.OnairStatus == 1)
                {
                    goto Reconnect;
                }
            }
        }
        public async Task ConnectAsync(string input, IBrowserProfile browserProfile)
        {
            BeforeConnecting();
            try
            {
                await ConnectInternalAsync(input, browserProfile);
            }
            finally
            {
                AfterDisconnected();
            }
        }
        private void WebSocket_Received(object sender, IPacket e)
        {
            try
            {
                if (e is PacketMessageEventMessageChat chat)
                {
                    var comment = Tools.Parse(chat.Comment);
                    var commentData = Tools.CreateCommentData(comment, _startAt, _siteOptions);
                    var messageContext = CreateMessageContext(comment, commentData, false);
                    if (messageContext != null)
                    {
                        MessageReceived?.Invoke(this, messageContext);
                    }
                }
                else if (e is PacketMessageEventMessageAudienceCount audienceCount)
                {
                    var ac = audienceCount.AudienceCount;
                    MetadataUpdated?.Invoke(this, new Metadata
                    {
                        CurrentViewers = ac.live_viewers.ToString(),
                        TotalViewers = ac.viewers.ToString(),
                    });
                }
                else if (e is PacketMessageEventMessageLiveEnd liveEnd)
                {
                    Disconnect();
                }
            }
            catch (Exception ex)
            {
                _logger.LogException(ex);
            }
        }
    }
}

Websocket.cs

using System;
using System.Threading.Tasks;
using System.Threading;
using WebSocket4Net;
using System.Diagnostics;
using System.Collections.Generic;

namespace OpenrecSitePlugin
{
    class Websocket
    {
        public event EventHandler Opened;
        public event EventHandler<string> Received;
        WebSocket _ws;

        private void Log(string str)
        {
            Debug.Write(DateTime.Now.ToString("yyyy/MM/dd HH:mm:ss"));
            Debug.WriteLine(str);
        }
        public Task ReceiveAsync(string url, List<KeyValuePair<string, string>> cookies, string userAgent, string origin)
        {
            if (_ws != null)
                throw new InvalidOperationException("_ws is not null");

            var tcs = new TaskCompletionSource<object>();
            var subProtocol = "";
            _ws = new WebSocket4Net.WebSocket(url, subProtocol, cookies, null, userAgent, origin, WebSocket4Net.WebSocketVersion.Rfc6455)
            {
                EnableAutoSendPing = false,
                AutoSendPingInterval = 0,
                ReceiveBufferSize = 8192,
                NoDelay = true
            };
            _ws.MessageReceived += (s, e) =>
            {
                Log("_ws.MessageReceived: " + e.Message);
                Received?.Invoke(this, e.Message);
            };
            _ws.DataReceived += (s, e) =>
            {
                //ここに来たことは今のところ一度もない。
                Debug.WriteLine("Dataが送られてきた");
            };
            _ws.Opened += (s, e) =>
            {
                Log("_ws.Opend");
                Opened?.Invoke(this, EventArgs.Empty);
            };
            _ws.Closed += (s, e) =>
            {
                Log("_ws.Closed");
                try
                {
                    tcs.TrySetResult(null);
                }
                finally
                {
                    if (_ws != null)
                    {
                        _ws.Dispose();
                        _ws = null;
                    }
                }
            };
            _ws.Error += (s, e) =>
            {
                Log("_ws.Error");
                try
                {
                    tcs.SetException(e.Exception);
                }
                finally
                {
                    if (_ws != null)
                    {
                        _ws.Dispose();
                        _ws = null;
                    }
                }
            };
            _ws.Open();
            return tcs.Task;
        }
        public async Task SendAsync(string str)
        {
            await Task.Yield();
            if (_ws != null)
            {
                _ws.Send(str);
                Debug.WriteLine("websocket send:" + str);
            }
            await Task.CompletedTask;
        }
        public void Disconnect()
        {
            if (_ws != null)
            {
                _ws.Close();
            }
        }
        public Websocket()
        {

        }
    }
}

OpenrecWebsocket.cs

using System;
using System.Threading.Tasks;
using System.Diagnostics;
using System.Collections.Generic;
using System.Net;
using Common;

namespace OpenrecSitePlugin
{
    class OpenrecWebsocket : IOpenrecWebsocket
    {
        private Websocket _websocket;
        private readonly ILogger _logger;
        //public event EventHandler<IOpenrecCommentData> CommentReceived;
        public event EventHandler<IPacket> Received;

        public async Task ReceiveAsync(string movieId, string userAgent, List<Cookie> cookies)
        {
            var cookieList = new List<KeyValuePair<string, string>>();
            foreach (Cookie cookie in cookies)
            {
                if (cookie.Name == "AWSALB")
                {
                    cookieList.Add(new KeyValuePair<string, string>(cookie.Name, cookie.Value));
                }
            }
            var origin = "https://www.openrec.tv";

            _websocket = new Websocket();
            _websocket.Received += Websocket_Received;
            _websocket.Opened += Websocket_Opened;
            var url = $"wss://chat.openrec.tv/socket.io/?movieId={movieId}&EIO=3&transport=websocket";
            await _websocket.ReceiveAsync(url, cookieList, userAgent, origin);
            //切断後処理
            _heartbeatTimer.Enabled = false;

        }

        private void Websocket_Opened(object sender, EventArgs e)
        {
            _heartbeatTimer.Enabled = true;
        }

        private void Websocket_Received(object sender, string e)
        {
            Debug.WriteLine(e);
            IPacket packet = null;
            try
            {
                packet = Packet.Parse(e);
            }
            catch (ParseException ex)
            {
                _logger.LogException(ex);
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.Message);
            }
            if (packet == null)
                return;
            Received?.Invoke(this, packet);
        }

        public async Task SendAsync(IPacket packet)
        {
            if (packet is PacketPing ping)
            {
                await SendAsync(ping.Raw);
            }
            else
            {
                throw new NotImplementedException();
            }
        }
        public async Task SendAsync(string s)
        {
            await _websocket.SendAsync(s);
        }
        System.Timers.Timer _heartbeatTimer = new System.Timers.Timer();
        public OpenrecWebsocket(ILogger logger)
        {
            _logger = logger;
            _heartbeatTimer.Interval = 25 * 1000;
            _heartbeatTimer.Elapsed += _heartBeatTimer_Elapsed;
            _heartbeatTimer.AutoReset = true;
        }
        private async void _heartBeatTimer_Elapsed(object sender, System.Timers.ElapsedEventArgs e)
        {
            try
            {
                await SendAsync(new PacketPing());
            }
            catch (Exception ex)
            {
                Debug.WriteLine(ex.Message);
            }
        }

        public void Disconnect()
        {
            _websocket.Disconnect();
        }
    }
}

Tools.cs

結構ながくなるので割愛します。

ただ、これはサイトごとの設定ファイルで、結構重要なファイル。

開発手順

後日、余力があれば書きます。。。

コメント