using System.Collections.Generic; using System.ComponentModel; using Newtonsoft.Json; using System.Net.Http.Json; using ttrss_co_client.ttrss.messages; using ttrss_co_client.ttrss.datastructures; // https://tt-rss.org/wiki/ApiReference //TODO: a lot of these are a given at API level 1, so double check all necessary API levels namespace ttrss_co_client.ttrss { public class ApiClient { public Uri BaseURI { get; private set; } private HttpClient httpClient { get; set; } private string SessionId { get; set; } = null; private int api_level { get; set; } public ApiClient(Uri baseUri) { BaseURI = baseUri; httpClient = new HttpClient() { BaseAddress = baseUri }; } public async Task Login(string username, string password) { var json = new { op = "login", user = username, password = password }; var content = JsonContent.Create(json); var response = await (await httpClient.PostAsync(BaseURI, content)).Content.ReadAsStringAsync(); var loginResult = JsonConvert.DeserializeObject>(response); if (loginResult.status == 0) { SessionId = loginResult.Content.session_id; if(loginResult.Content.api_level == null) { throw new NotImplementedException($"api doesn't report an api level - unsupported. api level 1 is version 1.5.8, so {BaseURI} might be extremely old. (or maybe this library is extremely old and ttrss changed again?)"); } else { api_level = loginResult.Content.api_level.Value; } Console.WriteLine(SessionId); } else { throw new NotImplementedException(); } } public async Task GetApiLevel() { //1.5.8 = api level 1, any lower than that, unsupported. //???? = 2 //???? = 3 //1.6.0 = 4 //1.7.6 = 5 //1.8 = ???? //???? = 6 //???? = 7 //???? = 8 //1.14 = 9 assertInitialized(); return await getOneValue("getApiLevel", "level"); } public async Task Logout() { assertInitialized(); return (await getOneValue("logout", "status"))?.ToLower() == "ok"; } public async Task IsLoggedIn() { //assertInitialized(); return (await getOneValue("isLoggedIn", "status")); } public async Task GetUnread() { assertInitialized(); return await getOneValue("getUnread", "unread"); } ///at least in my installation, it doesn't seem to respect the parameters I give it, be it curl or here. public async Task> GetCounters(bool feeds = true, bool labels = true, bool categories = true, bool tags = false) { assertInitialized(); var output_mode = ""; if (feeds) output_mode += "f"; if (labels) output_mode += "l"; if (categories) output_mode += "c"; if (tags) output_mode += "t"; var json = JsonContent.Create(new { op = "getCounters", sid = this.SessionId, output_mode = output_mode }); var response = await (await httpClient.PostAsync(BaseURI, json)).Content.ReadAsStringAsync(); var apiResult = JsonConvert.DeserializeObject>>(response); return apiResult.Content; } ///0 for all ///skip this amount first ///idk, doesn't affect what the documentation says it should public async Task> GetFeeds(int cat_id = 0, bool unread_only=false, uint limit = 0, int offset = 0/*, bool include_nested*/) { assertInitialized(); if(cat_id <-2) { if(cat_id == -3 || cat_id == -4) { assertApiLevel(4); } else { throw new IndexOutOfRangeException($"cat_id {cat_id} is out of range"); } } var json = JsonContent.Create(new { op = "getFeeds", sid = this.SessionId, cat_id = cat_id, unread_only = unread_only, limit = limit, offset = offset }); var response = await (await httpClient.PostAsync(BaseURI, json)).Content.ReadAsStringAsync(); var apiResult = JsonConvert.DeserializeObject>>(response); return apiResult.Content; } ///nested mode (return only top level) public async Task> GetCategories(bool unread_only = false, bool enable_nested = false, bool include_empty = false) { assertInitialized(); var json = JsonContent.Create(new { op = "getCategories", sid = this.SessionId, unread_only = unread_only, enable_nested = enable_nested, include_empty = include_empty }); var response = await (await httpClient.PostAsync(BaseURI, json)).Content.ReadAsStringAsync(); var apiResult = JsonConvert.DeserializeObject>>(response); return apiResult.Content; } public enum VIEWMODE { All, Unread, Adaptive, Marked, Updated } public enum SORTORDER { Default, OldestFirst, NewestFirst } public async Task> GetHeadlines( int feed_id, bool is_cat, int limit=60, int skip=0, /*string filter,*/ bool show_excerpt = false, bool show_content=false, VIEWMODE view_mode = VIEWMODE.All, bool include_attachments = false, int? since_id = null, bool include_nested = false, SORTORDER order_by = SORTORDER.Default, bool sanitize = true, bool force_update = false, bool has_sandbox = false) { if(limit>60) { assertApiLevel(6); } if(limit > 200) { throw new ArgumentOutOfRangeException("limit", limit, "capped at 200"); } if(include_nested) { assertApiLevel(4); } string sortOrderString = ""; if (order_by != SORTORDER.Default) { assertApiLevel(5); switch (order_by) { case SORTORDER.OldestFirst: sortOrderString = "date_reverse"; break; case SORTORDER.NewestFirst: sortOrderString = "feed_dates"; break; } } if(sanitize == false) { //TODO: it's version 1.8.0, but no idea what version that is. I can narrow it down to 6, 7, or 8. assertApiLevel(6); } if(force_update) { assertApiLevel(9); } var json = JsonContent.Create(new { op = "getHeadlines", sid = this.SessionId, feed_id = feed_id, is_cat = is_cat, limit = limit, skip = skip, show_excerpt = show_excerpt, show_content = show_content, view_mode = view_mode.ToString("D"), include_attachments = include_attachments, since_id = since_id, include_nested = include_nested, order_by = sortOrderString, sanitize = sanitize, force_update = force_update, has_sandbox = has_sandbox }); return await getHeadlines(json); } public async Task> GetHeadlinesTag( string tag, int limit=200, int skip=0, /*string filter,*/ bool show_excerpt = false, bool show_content=false, VIEWMODE view_mode = VIEWMODE.All, bool include_attachments = false, int? since_id = null, bool include_nested = false, SORTORDER order_by = SORTORDER.Default, bool sanitize = true, bool force_update = false, bool has_sandbox = false /*bool include_header = false*/) { assertApiLevel(18); if(limit > 200) { throw new ArgumentOutOfRangeException("limit", limit, "capped at 200"); } string sortOrderString; switch(order_by) { case SORTORDER.OldestFirst: sortOrderString = "date_reverse"; break; case SORTORDER.NewestFirst: sortOrderString = "feed_dates"; break; default: sortOrderString = ""; break; } var json = JsonContent.Create(new { op = "getHeadlines", sid = this.SessionId, feed_id = tag, limit = limit, skip = skip, show_excerpt = show_excerpt, show_content = show_content, view_mode = view_mode.ToString("D"), include_attachments = include_attachments, since_id = since_id, include_nested = include_nested, order_by = sortOrderString, sanitize = sanitize, force_update = force_update, has_sandbox = has_sandbox }); return await getHeadlines(json); } private async Task> getHeadlines(JsonContent parameters) { var response = await (await httpClient.PostAsync(BaseURI, parameters)).Content.ReadAsStringAsync(); var apiResult = JsonConvert.DeserializeObject>>(response); return apiResult.Content; } #region Get headlines include_header=true // public async Task>> GetHeadlinesAndHeader( // int feed_id, // bool is_cat, // int limit=60, // int skip=0, // /*string filter,*/ // bool show_excerpt = false, // bool show_content=false, // VIEWMODE view_mode = VIEWMODE.All, // bool include_attachments = false, // int? since_id = null, // bool include_nested = false, // SORTORDER order_by = SORTORDER.Default, // bool sanitize = true, // bool force_update = false, // bool has_sandbox = false) // { // assertApiLevel(12); // if(limit > 200) // { // throw new ArgumentOutOfRangeException("limit", limit, "capped at 200"); // } // string sortOrderString = ""; // if (order_by != SORTORDER.Default) // { // switch (order_by) // { // case SORTORDER.OldestFirst: // sortOrderString = "date_reverse"; // break; // case SORTORDER.NewestFirst: // sortOrderString = "feed_dates"; // break; // } // } // var json = JsonContent.Create(new // { // op = "getHeadlines", // sid = this.SessionId, // feed_id = feed_id, // is_cat = is_cat, // limit = limit, // skip = skip, // show_excerpt = show_excerpt, // show_content = show_content, // view_mode = view_mode.ToString("D"), // include_attachments = include_attachments, // since_id = since_id, // include_nested = include_nested, // order_by = sortOrderString, // sanitize = sanitize, // force_update = force_update, // has_sandbox = has_sandbox, // include_header = true // }); // return await getHeadlinesAndHeader(json); // } // public async Task GetHeadlinesTagAndHeader( // string tag, // int limit=200, // int skip=0, // /*string filter,*/ // bool show_excerpt = false, // bool show_content=false, // VIEWMODE view_mode = VIEWMODE.All, // bool include_attachments = false, // int? since_id = null, // bool include_nested = false, // SORTORDER order_by = SORTORDER.Default, // bool sanitize = true, // bool force_update = false, // bool has_sandbox = false // /*bool include_header = false*/) // { // assertApiLevel(18); // if(limit > 200) // { // throw new ArgumentOutOfRangeException("limit", limit, "capped at 200"); // } // string sortOrderString; // switch(order_by) // { // case SORTORDER.OldestFirst: // sortOrderString = "date_reverse"; // break; // case SORTORDER.NewestFirst: // sortOrderString = "feed_dates"; // break; // default: // sortOrderString = ""; // break; // } // var json = JsonContent.Create(new // { // op = "getHeadlines", // sid = this.SessionId, // feed_id = tag, // limit = limit, // skip = skip, // show_excerpt = show_excerpt, // show_content = show_content, // view_mode = view_mode.ToString("D"), // include_attachments = include_attachments, // since_id = since_id, // include_nested = include_nested, // order_by = sortOrderString, // sanitize = sanitize, // force_update = force_update, // has_sandbox = has_sandbox, // include_header = true // }); // return await getHeadlinesAndHeader(json); // } //todo: deserialize array of disparate types. can that be done? // private async Task>> getHeadlinesAndHeader(JsonContent parameters) // { // var response = await (await httpClient.PostAsync(BaseURI, parameters)).Content.ReadAsStringAsync(); // var immediateApiResult = JsonConvert.DeserializeObject>>(response).Content.ToList(); // var apiResult = JsonConvert.DeserializeObject>>(immediateApiResult[1]); // return apiResult; // } #endregion ///to update note, see public enum UPDATEFIELD { starred=0, published=1, unread=2 } public enum UPDATEMODE { SetFalse=0, SetTrue=1, Toggle=2 } public async Task UpdateArticleField(UPDATEFIELD field, UPDATEMODE mode, params int[] ids) { if(ids == null || ids.Length == 0) { throw new System.ArgumentNullException("ids", "need to specify at least one id"); } //documentation: UpdateArticle - for note, we have a separate method UpdateArticleNote assertInitialized(); var json = JsonContent.Create(new { op = "updateArticle", sid = this.SessionId, article_ids = string.Join(',', ids), mode = (int)mode, field = (int)field }); var response = await (await httpClient.PostAsync(BaseURI, json)).Content.ReadAsStringAsync(); var apiResult = JsonConvert.DeserializeObject>(response); return apiResult.Content.updated; throw new NotImplementedException(); } ///for fields other than note, we have a separate method UpdateArticleField public async Task UpdateArticleNote(string data, params int[] ids) { if(ids == null || ids.Length == 0) { throw new System.ArgumentNullException("ids", "need to specify at least one id"); } assertInitialized(); var json = JsonContent.Create(new { op = "updateArticle", sid = this.SessionId, article_ids = string.Join(',', ids), field = 3, data = data, }); var response = await (await httpClient.PostAsync(BaseURI, json)).Content.ReadAsStringAsync(); var apiResult = JsonConvert.DeserializeObject>(response); return apiResult.Content.updated; } public async Task> GetArticles(params int[] article_id) { if (!article_id.Any()) { throw new ArgumentException("need at least one article_id"); } assertInitialized(); var json = JsonContent.Create(new { op = "getArticle", sid = this.SessionId, article_id = string.Join(',', article_id) }); return await get>(json); } public async Task GetConfig() { assertInitialized(); var json = JsonContent.Create(new { op = "getConfig", sid = this.SessionId }); return await get(json); } /// ///tell the feed to update. As opposed to updating our configuration of the feed. /// public async Task UpdateFeed(int feed_id) { assertInitialized(); var json = JsonContent.Create(new { op = "updateFeed", sid = this.SessionId, feed_id = feed_id }); var apiResponse = await get>(json); return apiResponse.ContainsKey("status") && apiResponse["status"]?.ToLower() == "ok"; } public async Task GetPref(string pref) { assertInitialized(); var json = JsonContent.Create(new { op = "getPref", sid = this.SessionId, pref_name = pref }); var apiResponse = await get>(json); try { var converter = TypeDescriptor.GetConverter(typeof(T)); if (converter != null) { return (T)converter.ConvertFromString(apiResponse["value"]); } return default(T); } catch (NotSupportedException) { return default(T); } } public enum CATCHUPMODE { All, OneDay, OneWeek, TwoWeeks } ///Tries to catchup (e.g. mark as read) specified feed. public async Task CatchupFeed(int feed_id, bool is_cat, CATCHUPMODE mode = CATCHUPMODE.All) { assertInitialized(); var modestring = "all"; if(mode != CATCHUPMODE.All) { assertApiLevel(15); switch (mode) { case CATCHUPMODE.OneDay: modestring = "1day"; break; case CATCHUPMODE.OneWeek: modestring = "1week"; break; case CATCHUPMODE.TwoWeeks: modestring = "2week"; break; } } var json = JsonContent.Create(new { op = "catchupFeed", sid = this.SessionId, feed_id = feed_id, is_cat = is_cat, mode = modestring }); var apiResponse = await get>(json); return apiResponse.ContainsKey("status") && apiResponse["status"]?.ToLower() == "ok"; } public async Task> GetLabels(int? article_id) { assertInitialized(); var json = JsonContent.Create(new { op = "getLabels", sid = this.SessionId, article_id = article_id }); var labels = await get>(json); if(this.api_level < 5) { foreach(var l in labels) { l.id = -11 - l.id; } } return labels; } public async Task SetArticleLabel(int label_id, bool assign, params int[] article_ids) { //there's a label "cache", i guess? assertInitialized(); throw new NotImplementedException(); } public async Task ShareToPublished(string title, Uri url, string content, bool sanitize = true) { assertInitialized(); assertApiLevel(4); if (!sanitize) assertApiLevel(20); throw new NotImplementedException(); } /// ///if the feed requires basic HTTP auth; the login. (decidedly not self-explanatory, gdi) ///if the feed requires basic HTTP auth; the password. (decidedly not self-explanatory, gdi) /// public async Task SubscribeToFeed(Uri feed_url, int category_id, string login, string password) { assertInitialized(); assertApiLevel(5); throw new NotImplementedException(); } public async Task UnsubscribeFeed() { assertInitialized(); assertApiLevel(5); throw new NotImplementedException(); } public async Task GetFeedTree() { assertInitialized(); assertApiLevel(5); throw new NotImplementedException(); } private void assertInitialized() { if (SessionId == null) { throw new InvalidOperationException("no session ID - call Login first!"); } } private void assertApiLevel(int ApiLevel) { if (ApiLevel > this.api_level) { throw new NotSupportedException($"method requires api level {ApiLevel}, have {this.api_level}"); } } private async Task getOneValue(string op, string key) { //mostly you post {"op": "getAThing", "sid": "sessionId"} //and get back something like {"seq": 0, "status": 0, "content": {"the value you asked for": 0}} var json = JsonContent.Create(new { op = op, sid = this.SessionId }); var response = await (await httpClient.PostAsync(BaseURI, json)).Content.ReadAsStringAsync(); var apiResult = JsonConvert.DeserializeObject>>(response); try { var converter = TypeDescriptor.GetConverter(typeof(T)); if (converter != null) { return (T)converter.ConvertFromString(apiResult.Content[key]); } return default(T); } catch (NotSupportedException) { return default(T); } } private async Task get(JsonContent json) { var response = await (await httpClient.PostAsync(BaseURI, json)).Content.ReadAsStringAsync(); var apiResult = JsonConvert.DeserializeObject>(response); return apiResult.Content; } } }