Commit d8e069c6 authored by Robert Rudman's avatar Robert Rudman

Got websocket and events working. Allow authentication by cookie in config.

parent cd4f0f00
using System.IO;
using System;
using System.IO;
using System.Net.Http;
using System.Reactive.Linq;
using System.Threading.Tasks;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using StackExchangeChat.Console.AppSettings;
using StackExchangeChat.Sites;
namespace StackExchangeChat.Console
{
class Program
{
static void Main(string[] args)
public static void Main(string[] args)
{
System.Console.WriteLine("Hello World!");
......@@ -24,13 +28,34 @@ namespace StackExchangeChat.Console
var credentials = new Credentials();
config.Bind("ChatCredentials", credentials);
serviceCollection.AddTransient<HttpClient>();
serviceCollection.AddTransient(_ =>
{
var handler = new HttpClientHandler();
var httpClient = new HttpClient(handler);
return new HttpClientWithHandler
{
HttpClient = httpClient,
HttpClientHandler = handler
};
});
serviceCollection.AddSingleton(_ => config);
serviceCollection.AddSingleton(_ => credentials);
var serviceProvider = serviceCollection.BuildServiceProvider();
var chatClient = new ChatClient(serviceProvider);
chatClient.SubscribeToEvents(Site.StackOverflow, 167908)
.Where(c => c.EventType == EventType.MessagePosted || c.EventType == EventType.MessageEdited)
.Subscribe(async chatEvent =>
{
await chatClient.SendMessage(Site.StackOverflow, 167908, $":{chatEvent.MessageId} Replying to message..");
}, exception =>
{
System.Console.WriteLine(exception);
});
System.Console.ReadKey();
}
}
}
......@@ -11,6 +11,7 @@
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="2.2.0-preview3-35497" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.2.0-preview3-35497" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="2.2.0-preview3-35497" />
<PackageReference Include="System.Reactive" Version="4.1.2" />
</ItemGroup>
<ItemGroup>
......@@ -18,6 +19,9 @@
</ItemGroup>
<ItemGroup>
<None Update="appsettings.dev.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="appsettings.dev.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
......
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Reactive.Disposables;
using System.Reactive.Linq;
using System.Threading.Tasks;
using HtmlAgilityPack;
using Microsoft.Extensions.DependencyInjection;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using StackExchangeChat.Console.AppSettings;
using StackExchangeChat.Sites;
using WebSocketSharp;
namespace StackExchangeChat
{
public class ChatClient
{
private struct SiteRoomIdPair
{
public Site Site;
public int RoomId;
}
private class WSAuthResult
{
public string Url { get; set; }
}
private class EventsResult
{
public string Time { get; set; }
}
private readonly IServiceProvider _serviceProvider;
private DateTime? _cookieExpires;
private Task<Cookie> _authenticateTask;
private readonly object _locker = new object();
private readonly Dictionary<SiteRoomIdPair, Task<string>> m_CachedFKeys = new Dictionary<SiteRoomIdPair, Task<string>>();
public ChatClient(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public async Task SendMessage(Site site, int roomId, string message)
{
var fkey = await GetFKeyForRoom(site, roomId);
using (var httpClient = await GetAuthenticatedHttpClient(site))
{
await httpClient.PostAsync($"https://{site.ChatDomain}/chats/{roomId}/messages/new",
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{"text", message},
{"fkey", fkey}
}));
}
}
public IObservable<ChatEvent> SubscribeToEvents(Site site, int roomId)
{
return Observable.Create<ChatEvent>(async observer =>
{
var roomFKey = await GetFKeyForRoom(site, roomId);
WSAuthResult wsAuthResult;
EventsResult eventsResult;
using (var httpClient = await GetAuthenticatedHttpClient(site))
{
var wsAuthRequest = await httpClient.PostAsync($"https://{site.ChatDomain}/ws-auth",
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{"fkey", roomFKey},
{"roomid", roomId.ToString()}
}));
wsAuthResult = JsonConvert.DeserializeObject<WSAuthResult>(await wsAuthRequest.Content.ReadAsStringAsync());
var eventsRequest = await httpClient.PostAsync($"https://{site.ChatDomain}/chats/{roomId}/events",
new FormUrlEncodedContent(
new Dictionary<string, string>
{
{"mode", "events"},
{"msgCount", "0"},
{"fkey", roomFKey}
}));
eventsResult = JsonConvert.DeserializeObject<EventsResult>(await eventsRequest.Content.ReadAsStringAsync());
}
var webSocket = new WebSocket($"{wsAuthResult.Url}?l={eventsResult.Time}") {Origin = $"https://{site.ChatDomain}"};
webSocket.OnMessage += (sender, args) =>
{
var dataObject = JsonConvert.DeserializeObject<JObject>(args.Data);
var eventsObject = dataObject.First.First["e"];
if (eventsObject == null)
return;
var events = eventsObject.ToObject<List<ChatEvent>>();
foreach (var @event in events)
observer.OnNext(@event);
};
webSocket.Connect();
return Disposable.Create(() => { });
});
}
private async Task<HttpClient> GetAuthenticatedHttpClient(Site site)
{
var acctCookie = await GetAccountCookie(site);
var httpClientWithHandler = _serviceProvider.GetService<HttpClientWithHandler>();
var cookieContainer = new CookieContainer();
httpClientWithHandler.HttpClientHandler.CookieContainer = cookieContainer;
cookieContainer.Add(acctCookie);
return httpClientWithHandler.HttpClient;
}
private async Task<string> GetFKeyForRoom(Site site, int roomId)
{
var pair = new SiteRoomIdPair {Site = site, RoomId = roomId};
if (!m_CachedFKeys.ContainsKey(pair))
m_CachedFKeys[pair] = GetFKeyForRoomInternal();
return await m_CachedFKeys[pair];
async Task<string> GetFKeyForRoomInternal()
{
using (var httpClient = await GetAuthenticatedHttpClient(site))
{
var result = await httpClient.GetAsync($"https://{site.ChatDomain}/rooms/{roomId}");
var resultStr = await result.Content.ReadAsStringAsync();
var doc = new HtmlDocument();
doc.LoadHtml(resultStr);
var fkeyElement = doc.DocumentNode.SelectSingleNode("//input[@id = 'fkey']");
var fkey = fkeyElement.Attributes["value"].Value;
return fkey;
}
}
}
private async Task<Cookie> GetAccountCookie(Site site)
{
lock (_locker)
{
if (_cookieExpires.HasValue && _cookieExpires.Value < DateTime.UtcNow)
_authenticateTask = null;
if (_authenticateTask == null)
_authenticateTask = GetAccountCookieInternal();
}
var cookie = await _authenticateTask;
_cookieExpires = cookie.Expires;
return cookie;
async Task<Cookie> GetAccountCookieInternal()
{
var credentials = _serviceProvider.GetService<Credentials>();
if (!string.IsNullOrWhiteSpace(credentials.AcctCookie))
{
var expiry = DateTime.ParseExact(credentials.AcctCookieExpiry, "yyyy-MM-dd HH:mm:ssZ", CultureInfo.InvariantCulture);
return new Cookie("acct", credentials.AcctCookie, "/", site.LoginDomain)
{
Expires = expiry
};
}
using (var httpClientWithHandler = _serviceProvider.GetService<HttpClientWithHandler>())
{
var cookieContainer = new CookieContainer();
httpClientWithHandler.HttpClientHandler.CookieContainer = cookieContainer;
var httpClient = httpClientWithHandler.HttpClient;
var result = await httpClient.GetAsync($"https://{site.LoginDomain}/users/login");
var content = await result.Content.ReadAsStringAsync();
var doc = new HtmlDocument();
doc.LoadHtml(content);
var fkeyElement = doc.DocumentNode.SelectSingleNode("//form[@id = 'login-form']/input[@name = 'fkey']");
var fkey = fkeyElement.Attributes["value"].Value;
var loginPayload = new FormUrlEncodedContent(new Dictionary<string, string>
{
{"fkey", fkey},
{"email", credentials.Email},
{"password", credentials.Password},
});
await httpClient.PostAsync($"https://{site.LoginDomain}/users/login", loginPayload);
var cookies = cookieContainer.GetCookies(new Uri($"https://{site.LoginDomain}")).Cast<Cookie>().ToList();
var acctCookie = cookies.FirstOrDefault(c => c.Name == "acct");
if (acctCookie == null || acctCookie.Expired)
throw new Exception();
return acctCookie;
}
}
}
}
}
using Newtonsoft.Json;
namespace StackExchangeChat
{
public class ChatEvent
{
[JsonProperty(PropertyName = "event_type")]
public EventType EventType { get; set; }
[JsonProperty(PropertyName = "time_stamp")]
public int TimeStamp { get; set; }
public string Content { get; set; }
public int Id { get; set; }
[JsonProperty(PropertyName = "user_id")]
public int UserId { get; set; }
[JsonProperty(PropertyName = "user_name")]
public string UserName { get; set; }
[JsonProperty(PropertyName = "room_id")]
public int RoomId { get; set; }
[JsonProperty(PropertyName = "room_name")]
public string RoomName { get; set; }
[JsonProperty(PropertyName = "message_id")]
public int? MessageId { get; set; }
}
}
using System;
namespace StackExchangeChat
{
public class ChatClient
{
private readonly IServiceProvider _serviceProvider;
public ChatClient(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
}
}
......@@ -2,7 +2,10 @@
{
public class Credentials
{
public string Username { get; set; }
public string AcctCookie { get; set; }
public string AcctCookieExpiry { get; set; }
public string Email { get; set; }
public string Password { get; set; }
}
}
namespace StackExchangeChat
{
// Copy & pasted from https://github.com/SOBotics/SharpExchange/blob/master/SharpExchange/Chat/Events/EventType.cs
public enum EventType
{
/// <summary>
/// A new message has been posted.
/// </summary>
MessagePosted = 1,
/// <summary>
/// A message has been edited.
/// </summary>
MessageEdited = 2,
/// <summary>
/// A user has entered the room.
/// </summary>
UserEntered = 3,
/// <summary>
/// A user has left the room.
/// </summary>
UserLeft = 4,
/// <summary>
/// The room's name, description and/or tags have been changed.
/// </summary>
RoomNameChanged = 5,
/// <summary>
/// Someone has (un)starred a message.
/// </summary>
MessageStarToggled = 6,
/// <summary>
/// No idea.
/// </summary>
DebugMessage = 7,
/// <summary>
/// The current account has been mentioned (@Username) in a message.
/// </summary>
UserMentioned = 8,
/// <summary>
/// A message has been flagged as spam/offensive.
/// </summary>
MessageFlagged = 9,
/// <summary>
/// A message has been deleted.
/// </summary>
MessageDeleted = 10,
/// <summary>
/// A file has been uploaded to the room.
/// Dev note: as far as I know, only one room supports this publicly,
/// the Android SE testing app room.
/// </summary>
FileAdded = 11,
/// <summary>
/// A message has been flagged for moderator attention.
/// </summary>
ModeratorFlag = 12,
/// <summary>
/// No idea.
/// </summary>
UserSettingsChanged = 13,
/// <summary>
/// No idea.
/// </summary>
GlobalNotification = 14,
/// <summary>
/// A user's room access level has been changed.
/// </summary>
UserAccessLevelChanged = 15,
/// <summary>
/// No idea.
/// </summary>
UserNotification = 16,
/// <summary>
/// The current account has been invited to join another room.
/// </summary>
RoomInvitation = 17,
/// <summary>
/// Someone has posted a direct reply to a message posted by this account.
/// </summary>
MessageReply = 18,
/// <summary>
/// A room owner/moderator has moved a message out of the room.
/// </summary>
MessageMovedOut = 19,
/// <summary>
/// A room owner/moderator has moved a message into the room.
/// </summary>
MessageMovedIn = 20,
/// <summary>
/// No idea.
/// </summary>
TimeBreak = 21,
/// <summary>
/// No idea.
/// </summary>
FeedTicker = 22,
/// <summary>
/// A user has been suspended.
/// </summary>
UserSuspended = 29,
/// <summary>
/// Two user accounts have been merged.
/// </summary>
UserMerged = 30,
/// <summary>
/// A user's name or avatar has been updated.
/// </summary>
UserNameOrAvatarChanged = 34
}
}
using System;
using System.Net.Http;
namespace StackExchangeChat
{
public class HttpClientWithHandler : IDisposable
{
public HttpClient HttpClient { get; set; }
public HttpClientHandler HttpClientHandler { get; set; }
public void Dispose()
{
HttpClient?.Dispose();
HttpClientHandler?.Dispose();
}
}
}
namespace StackExchangeChat.Sites
{
public class Site
{
public string ChatDomain { get; }
public string LoginDomain { get; }
private Site(string chatDomain, string loginDomain)
{
ChatDomain = chatDomain;
LoginDomain = loginDomain;
}
public static Site StackOverflow = new Site("chat.stackoverflow.com", "stackoverflow.com");
public static Site StackExchange = new Site("chat.stackexchange.com", "gaming.stackexchange.com");
public static Site MetaStackExchange = new Site("chat.meta.stackexchange.com", "meta.stackexchange.com");
}
}
......@@ -5,8 +5,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.8.10" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="2.2.0-preview3-35497" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="2.2.0-preview3-35497" />
<PackageReference Include="Newtonsoft.Json" Version="12.0.1-beta1" />
<PackageReference Include="System.Reactive" Version="4.1.2" />
<PackageReference Include="WebSocketSharp" Version="1.0.3-rc11" />
</ItemGroup>
</Project>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment