diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f46d010a..270ac103d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +# [4.0.6] + +## Added + +- **All bindings:** `ContentContext` adds two new methods (Rust, Go, C, C++, Java, Python, Node.js): + - `topics_mine(opts)` — get topics created by the current authenticated user, with optional page/size/topic_type filtering. + - `create_topic(opts)` — create a new topic; returns the full `OwnedTopic` on success. +- **All bindings:** New types `OwnedTopic`, `ListMyTopicsOptions`, and `CreateTopicOptions` to support the above methods. + # [4.0.5] ## Changed diff --git a/c/cbindgen.toml b/c/cbindgen.toml index c7094fb3c..66cde5f24 100644 --- a/c/cbindgen.toml +++ b/c/cbindgen.toml @@ -130,6 +130,9 @@ cpp_compat = true "CMarketTemperature" = "lb_market_temperature_t" "CHistoryMarketTemperatureResponse" = "lb_history_market_temperature_response_t" "CFilingItem" = "lb_filing_item_t" +"CTopicAuthor" = "lb_topic_author_t" +"CTopicImage" = "lb_topic_image_t" +"COwnedTopic" = "lb_owned_topic_t" "CTopicItem" = "lb_topic_item_t" "CNewsItem" = "lb_news_item_t" "CContentContext" = "lb_content_context_t" @@ -174,6 +177,9 @@ include = [ "CMarketTemperature", "CHistoryMarketTemperatureResponse", "CFilingItem", + "CTopicAuthor", + "CTopicImage", + "COwnedTopic", "CTopicItem", "CNewsItem", "COAuth", diff --git a/c/csrc/include/longbridge.h b/c/csrc/include/longbridge.h index 3d4d970c8..5a583ac74 100644 --- a/c/csrc/include/longbridge.h +++ b/c/csrc/include/longbridge.h @@ -3909,6 +3909,128 @@ typedef struct lb_filing_item_t { int64_t published_at; } lb_filing_item_t; +/** + * Topic author + */ +typedef struct lb_topic_author_t { + /** + * Member ID + */ + const char *member_id; + /** + * Display name + */ + const char *name; + /** + * Avatar URL + */ + const char *avatar; +} lb_topic_author_t; + +/** + * Topic image + */ +typedef struct lb_topic_image_t { + /** + * Original image URL + */ + const char *url; + /** + * Small thumbnail URL + */ + const char *sm; + /** + * Large image URL + */ + const char *lg; +} lb_topic_image_t; + +/** + * My topic item (topic created by the current authenticated user) + */ +typedef struct lb_owned_topic_t { + /** + * Topic ID + */ + const char *id; + /** + * Title + */ + const char *title; + /** + * Plain text excerpt + */ + const char *description; + /** + * Markdown body + */ + const char *body; + /** + * Author + */ + struct lb_topic_author_t author; + /** + * Related stock tickers + */ + const char *const *tickers; + /** + * Number of tickers + */ + uintptr_t num_tickers; + /** + * Hashtag names + */ + const char *const *hashtags; + /** + * Number of hashtags + */ + uintptr_t num_hashtags; + /** + * Images + */ + const struct lb_topic_image_t *images; + /** + * Number of images + */ + uintptr_t num_images; + /** + * Likes count + */ + int32_t likes_count; + /** + * Comments count + */ + int32_t comments_count; + /** + * Views count + */ + int32_t views_count; + /** + * Shares count + */ + int32_t shares_count; + /** + * Content type: "article" or "post" + */ + const char *topic_type; + /** + * License: 0=none, 1=original, 2=non-original + */ + int32_t license; + /** + * URL to the full topic page + */ + const char *detail_url; + /** + * Created time (Unix timestamp) + */ + int64_t created_at; + /** + * Updated time (Unix timestamp) + */ + int64_t updated_at; +} lb_owned_topic_t; + /** * Topic item */ @@ -4123,6 +4245,50 @@ void lb_content_context_retain(const struct lb_content_context_t *ctx); */ void lb_content_context_release(const struct lb_content_context_t *ctx); +/** + * Get topics created by the current authenticated user + * + * @param ctx Content context + * @param page Page number (0 = default 1) + * @param size Records per page, range 1~500 (0 = default 50) + * @param topic_type Filter by content type: "article" or "post" (NULL = all) + * @param callback Async callback + * @param userdata User data passed to the callback + */ +void lb_content_context_topics_mine(const struct lb_content_context_t *ctx, + int32_t page, + int32_t size, + const char *topic_type, + lb_async_callback_t callback, + void *userdata); + +/** + * Create a new topic + * + * @param ctx Content context + * @param title Topic title (required) + * @param body Topic body in Markdown format (required) + * @param topic_type Type: "article" or "post" (NULL = "post") + * @param tickers Related stock tickers array (NULL = none) + * @param num_tickers Number of tickers + * @param hashtags Hashtag names array (NULL = none) + * @param num_hashtags Number of hashtags + * @param license 0=none, 1=original, 2=non-original (-1 = default) + * @param callback Async callback + * @param userdata User data passed to the callback + */ +void lb_content_context_create_topic(const struct lb_content_context_t *ctx, + const char *title, + const char *body, + const char *topic_type, + const char *const *tickers, + uintptr_t num_tickers, + const char *const *hashtags, + uintptr_t num_hashtags, + int32_t license, + lb_async_callback_t callback, + void *userdata); + /** * Get discussion topics list for a symbol * diff --git a/c/src/content_context/context.rs b/c/src/content_context/context.rs index 407a6bc12..29f8afe00 100644 --- a/c/src/content_context/context.rs +++ b/c/src/content_context/context.rs @@ -1,12 +1,12 @@ use std::{ffi::c_void, os::raw::c_char, sync::Arc}; -use longbridge::content::ContentContext; +use longbridge::content::{ContentContext, CreateTopicOptions, ListMyTopicsOptions}; use crate::{ async_call::{CAsyncCallback, execute_async}, config::CConfig, - content_context::types::{CNewsItemOwned, CTopicItemOwned}, - types::{CVec, cstr_to_rust}, + content_context::types::{CNewsItemOwned, COwnedTopicOwned, CTopicItemOwned}, + types::{CVec, cstr_array_to_rust, cstr_to_rust}, }; /// Content context @@ -37,6 +37,104 @@ pub unsafe extern "C" fn lb_content_context_release(ctx: *const CContentContext) let _ = Arc::from_raw(ctx); } +/// Get topics created by the current authenticated user +/// +/// @param ctx Content context +/// @param page Page number (0 = default 1) +/// @param size Records per page, range 1~500 (0 = default 50) +/// @param topic_type Filter by content type: "article" or "post" (NULL = all) +/// @param callback Async callback +/// @param userdata User data passed to the callback +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_content_context_topics_mine( + ctx: *const CContentContext, + page: i32, + size: i32, + topic_type: *const c_char, + callback: CAsyncCallback, + userdata: *mut c_void, +) { + let ctx_inner = (*ctx).ctx.clone(); + let topic_type = if topic_type.is_null() { + None + } else { + Some(cstr_to_rust(topic_type)) + }; + execute_async(callback, ctx, userdata, async move { + let rows: CVec = ctx_inner + .topics_mine(ListMyTopicsOptions { + page: if page > 0 { Some(page) } else { None }, + size: if size > 0 { Some(size) } else { None }, + topic_type, + }) + .await? + .into(); + Ok(rows) + }); +} + +/// Create a new topic +/// +/// @param ctx Content context +/// @param title Topic title (required) +/// @param body Topic body in Markdown format (required) +/// @param topic_type Type: "article" or "post" (NULL = "post") +/// @param tickers Related stock tickers array (NULL = none) +/// @param num_tickers Number of tickers +/// @param hashtags Hashtag names array (NULL = none) +/// @param num_hashtags Number of hashtags +/// @param license 0=none, 1=original, 2=non-original (-1 = default) +/// @param callback Async callback +/// @param userdata User data passed to the callback +#[unsafe(no_mangle)] +pub unsafe extern "C" fn lb_content_context_create_topic( + ctx: *const CContentContext, + title: *const c_char, + body: *const c_char, + topic_type: *const c_char, + tickers: *const *const c_char, + num_tickers: usize, + hashtags: *const *const c_char, + num_hashtags: usize, + license: i32, + callback: CAsyncCallback, + userdata: *mut c_void, +) { + let ctx_inner = (*ctx).ctx.clone(); + let title = cstr_to_rust(title); + let body = cstr_to_rust(body); + let topic_type = if topic_type.is_null() { + None + } else { + Some(cstr_to_rust(topic_type)) + }; + let tickers = if tickers.is_null() || num_tickers == 0 { + None + } else { + Some(cstr_array_to_rust(tickers, num_tickers)) + }; + let hashtags = if hashtags.is_null() || num_hashtags == 0 { + None + } else { + Some(cstr_array_to_rust(hashtags, num_hashtags)) + }; + let license = if license >= 0 { Some(license) } else { None }; + execute_async(callback, ctx, userdata, async move { + let owned = ctx_inner + .create_topic(CreateTopicOptions { + title, + body, + topic_type, + tickers, + hashtags, + license, + }) + .await?; + let rows: CVec = vec![COwnedTopicOwned::from(owned)].into(); + Ok(rows) + }); +} + /// Get discussion topics list for a symbol /// /// @param ctx Content context diff --git a/c/src/content_context/types.rs b/c/src/content_context/types.rs index f864d6e89..52f965510 100644 --- a/c/src/content_context/types.rs +++ b/c/src/content_context/types.rs @@ -1,8 +1,207 @@ use std::os::raw::c_char; -use longbridge::content::{NewsItem, TopicItem}; +use longbridge::content::{NewsItem, OwnedTopic, TopicAuthor, TopicImage, TopicItem}; -use crate::types::{CString, ToFFI}; +use crate::{ + async_call::CAsyncResult, + types::{CString, CVec, ToFFI}, +}; + +/// Topic author +#[repr(C)] +pub struct CTopicAuthor { + /// Member ID + pub member_id: *const c_char, + /// Display name + pub name: *const c_char, + /// Avatar URL + pub avatar: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CTopicAuthorOwned { + member_id: CString, + name: CString, + avatar: CString, +} + +impl From for CTopicAuthorOwned { + fn from(a: TopicAuthor) -> Self { + Self { + member_id: a.member_id.into(), + name: a.name.into(), + avatar: a.avatar.into(), + } + } +} + +impl ToFFI for CTopicAuthorOwned { + type FFIType = CTopicAuthor; + fn to_ffi_type(&self) -> CTopicAuthor { + CTopicAuthor { + member_id: self.member_id.to_ffi_type(), + name: self.name.to_ffi_type(), + avatar: self.avatar.to_ffi_type(), + } + } +} + +/// Topic image +#[repr(C)] +pub struct CTopicImage { + /// Original image URL + pub url: *const c_char, + /// Small thumbnail URL + pub sm: *const c_char, + /// Large image URL + pub lg: *const c_char, +} + +#[derive(Debug)] +pub(crate) struct CTopicImageOwned { + url: CString, + sm: CString, + lg: CString, +} + +impl From for CTopicImageOwned { + fn from(img: TopicImage) -> Self { + Self { + url: img.url.into(), + sm: img.sm.into(), + lg: img.lg.into(), + } + } +} + +impl ToFFI for CTopicImageOwned { + type FFIType = CTopicImage; + fn to_ffi_type(&self) -> CTopicImage { + CTopicImage { + url: self.url.to_ffi_type(), + sm: self.sm.to_ffi_type(), + lg: self.lg.to_ffi_type(), + } + } +} + +/// My topic item (topic created by the current authenticated user) +#[repr(C)] +pub struct COwnedTopic { + /// Topic ID + pub id: *const c_char, + /// Title + pub title: *const c_char, + /// Plain text excerpt + pub description: *const c_char, + /// Markdown body + pub body: *const c_char, + /// Author + pub author: CTopicAuthor, + /// Related stock tickers + pub tickers: *const *const c_char, + /// Number of tickers + pub num_tickers: usize, + /// Hashtag names + pub hashtags: *const *const c_char, + /// Number of hashtags + pub num_hashtags: usize, + /// Images + pub images: *const CTopicImage, + /// Number of images + pub num_images: usize, + /// Likes count + pub likes_count: i32, + /// Comments count + pub comments_count: i32, + /// Views count + pub views_count: i32, + /// Shares count + pub shares_count: i32, + /// Content type: "article" or "post" + pub topic_type: *const c_char, + /// License: 0=none, 1=original, 2=non-original + pub license: i32, + /// URL to the full topic page + pub detail_url: *const c_char, + /// Created time (Unix timestamp) + pub created_at: i64, + /// Updated time (Unix timestamp) + pub updated_at: i64, +} + +#[derive(Debug)] +pub(crate) struct COwnedTopicOwned { + id: CString, + title: CString, + description: CString, + body: CString, + author: CTopicAuthorOwned, + tickers: CVec, + hashtags: CVec, + images: CVec, + likes_count: i32, + comments_count: i32, + views_count: i32, + shares_count: i32, + topic_type: CString, + license: i32, + detail_url: CString, + created_at: i64, + updated_at: i64, +} + +impl From for COwnedTopicOwned { + fn from(item: OwnedTopic) -> Self { + Self { + id: item.id.into(), + title: item.title.into(), + description: item.description.into(), + body: item.body.into(), + author: item.author.into(), + tickers: item.tickers.into(), + hashtags: item.hashtags.into(), + images: item.images.into(), + likes_count: item.likes_count, + comments_count: item.comments_count, + views_count: item.views_count, + shares_count: item.shares_count, + topic_type: item.topic_type.into(), + license: item.license, + detail_url: item.detail_url.into(), + created_at: item.created_at.unix_timestamp(), + updated_at: item.updated_at.unix_timestamp(), + } + } +} + +impl ToFFI for COwnedTopicOwned { + type FFIType = COwnedTopic; + fn to_ffi_type(&self) -> COwnedTopic { + COwnedTopic { + id: self.id.to_ffi_type(), + title: self.title.to_ffi_type(), + description: self.description.to_ffi_type(), + body: self.body.to_ffi_type(), + author: self.author.to_ffi_type(), + tickers: self.tickers.to_ffi_type(), + num_tickers: self.tickers.len(), + hashtags: self.hashtags.to_ffi_type(), + num_hashtags: self.hashtags.len(), + images: self.images.to_ffi_type(), + num_images: self.images.len(), + likes_count: self.likes_count, + comments_count: self.comments_count, + views_count: self.views_count, + shares_count: self.shares_count, + topic_type: self.topic_type.to_ffi_type(), + license: self.license, + detail_url: self.detail_url.to_ffi_type(), + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} /// Topic item #[repr(C)] diff --git a/cpp/include/content_context.hpp b/cpp/include/content_context.hpp index 1a436b078..6ce2562f5 100644 --- a/cpp/include/content_context.hpp +++ b/cpp/include/content_context.hpp @@ -27,6 +27,14 @@ class ContentContext static ContentContext create(const Config& config); + /// Get topics created by the current authenticated user + void topics_mine(const ListMyTopicsOptions& opts, + AsyncCallback> callback) const; + + /// Create a new topic + void create_topic(const CreateTopicOptions& opts, + AsyncCallback callback) const; + /// Get discussion topics list for a symbol void topics(const std::string& symbol, AsyncCallback> callback) const; diff --git a/cpp/include/types.hpp b/cpp/include/types.hpp index 032b13fa6..503e327e4 100644 --- a/cpp/include/types.hpp +++ b/cpp/include/types.hpp @@ -2127,6 +2127,95 @@ struct NewsItem int32_t shares_count; }; +/// Topic author +struct TopicAuthor +{ + /// Member ID + std::string member_id; + /// Display name + std::string name; + /// Avatar URL + std::string avatar; +}; + +/// Topic image +struct TopicImage +{ + /// Original image URL + std::string url; + /// Small thumbnail URL + std::string sm; + /// Large image URL + std::string lg; +}; + +/// My topic item (created by the current authenticated user) +struct OwnedTopic +{ + /// Topic ID + std::string id; + /// Title + std::string title; + /// Plain text excerpt + std::string description; + /// Markdown body + std::string body; + /// Author + TopicAuthor author; + /// Related stock tickers + std::vector tickers; + /// Hashtag names + std::vector hashtags; + /// Images + std::vector images; + /// Likes count + int32_t likes_count; + /// Comments count + int32_t comments_count; + /// Views count + int32_t views_count; + /// Shares count + int32_t shares_count; + /// Content type: "article" or "post" + std::string topic_type; + /// License: 0=none, 1=original, 2=non-original + int32_t license; + /// URL to the full topic page + std::string detail_url; + /// Created time (Unix timestamp) + int64_t created_at; + /// Updated time (Unix timestamp) + int64_t updated_at; +}; + +/// Options for listing topics created by the current authenticated user +struct ListMyTopicsOptions +{ + /// Page number (0 = default 1) + int32_t page = 0; + /// Records per page, range 1~500 (0 = default 50) + int32_t size = 0; + /// Filter by content type: "article" or "post" (empty = all) + std::string topic_type; +}; + +/// Options for creating a topic +struct CreateTopicOptions +{ + /// Topic title (required) + std::string title; + /// Topic body in Markdown format (required) + std::string body; + /// Content type: "article" or "post" (empty = default "post") + std::string topic_type; + /// Related stock tickers, format: {symbol}.{market}, max 10 + std::vector tickers; + /// Hashtag names, max 5 + std::vector hashtags; + /// License: 0=none (default), 1=original, 2=non-original (-1 = not set) + int32_t license = -1; +}; + } // namespace content } // namespace longbridge \ No newline at end of file diff --git a/cpp/src/content_context.cpp b/cpp/src/content_context.cpp index 0d200a0bb..78b30c7d7 100644 --- a/cpp/src/content_context.cpp +++ b/cpp/src/content_context.cpp @@ -63,6 +63,93 @@ ContentContext::create(const Config& config) return ctx; } +void +ContentContext::topics_mine( + const ListMyTopicsOptions& opts, + AsyncCallback> callback) const +{ + const char* topic_type = + opts.topic_type.empty() ? nullptr : opts.topic_type.c_str(); + lb_content_context_topics_mine( + ctx_, + opts.page, + opts.size, + topic_type, + [](auto res) { + auto callback_ptr = + callback::get_async_callback>( + res->userdata); + ContentContext ctx((const lb_content_context_t*)res->ctx); + Status status(res->error); + + if (status) { + auto rows = (const lb_owned_topic_t*)res->data; + std::vector rows2; + std::transform(rows, + rows + res->length, + std::back_inserter(rows2), + [](auto row) { return convert(&row); }); + + (*callback_ptr)( + AsyncResult>( + ctx, std::move(status), &rows2)); + } else { + (*callback_ptr)( + AsyncResult>( + ctx, std::move(status), nullptr)); + } + }, + new AsyncCallback>(callback)); +} + +void +ContentContext::create_topic( + const CreateTopicOptions& opts, + AsyncCallback callback) const +{ + const char* topic_type = + opts.topic_type.empty() ? nullptr : opts.topic_type.c_str(); + std::vector tickers_cstr; + for (const auto& t : opts.tickers) { + tickers_cstr.push_back(t.c_str()); + } + std::vector hashtags_cstr; + for (const auto& h : opts.hashtags) { + hashtags_cstr.push_back(h.c_str()); + } + lb_content_context_create_topic( + ctx_, + opts.title.c_str(), + opts.body.c_str(), + topic_type, + tickers_cstr.empty() ? nullptr : tickers_cstr.data(), + tickers_cstr.size(), + hashtags_cstr.empty() ? nullptr : hashtags_cstr.data(), + hashtags_cstr.size(), + opts.license, + [](auto res) { + auto callback_ptr = + callback::get_async_callback( + res->userdata); + ContentContext ctx((const lb_content_context_t*)res->ctx); + Status status(res->error); + + if (status) { + auto resp = (const lb_owned_topic_t*)res->data; + OwnedTopic result = convert(resp); + + (*callback_ptr)( + AsyncResult( + ctx, std::move(status), &result)); + } else { + (*callback_ptr)( + AsyncResult( + ctx, std::move(status), nullptr)); + } + }, + new AsyncCallback(callback)); +} + void ContentContext::topics( const std::string& symbol, diff --git a/cpp/src/convert.hpp b/cpp/src/convert.hpp index f4e82ff8d..e9e914d6b 100644 --- a/cpp/src/convert.hpp +++ b/cpp/src/convert.hpp @@ -104,7 +104,10 @@ using longbridge::trade::TimeInForceType; using longbridge::trade::TopicType; using longbridge::trade::TriggerStatus; using longbridge::quote::FilingItem; +using longbridge::content::OwnedTopic; using longbridge::content::NewsItem; +using longbridge::content::TopicAuthor; +using longbridge::content::TopicImage; using longbridge::content::TopicItem; inline lb_language_t @@ -2242,6 +2245,49 @@ convert(const lb_news_item_t* item) item->shares_count }; } +inline TopicAuthor +convert(const lb_topic_author_t* a) +{ + return TopicAuthor{ a->member_id, a->name, a->avatar }; +} + +inline TopicImage +convert(const lb_topic_image_t* img) +{ + return TopicImage{ img->url, img->sm, img->lg }; +} + +inline OwnedTopic +convert(const lb_owned_topic_t* item) +{ + std::vector tickers(item->tickers, + item->tickers + item->num_tickers); + std::vector hashtags(item->hashtags, + item->hashtags + item->num_hashtags); + std::vector images; + std::transform(item->images, + item->images + item->num_images, + std::back_inserter(images), + [](const lb_topic_image_t& img) { return convert(&img); }); + return OwnedTopic{ item->id, + item->title, + item->description, + item->body, + convert(&item->author), + std::move(tickers), + std::move(hashtags), + std::move(images), + item->likes_count, + item->comments_count, + item->views_count, + item->shares_count, + item->topic_type, + item->license, + item->detail_url, + item->created_at, + item->updated_at }; +} + } // namespace convert } // namespace longbridge diff --git a/java/javasrc/src/main/java/com/longbridge/SdkNative.java b/java/javasrc/src/main/java/com/longbridge/SdkNative.java index 95005a22c..b955ca854 100644 --- a/java/javasrc/src/main/java/com/longbridge/SdkNative.java +++ b/java/javasrc/src/main/java/com/longbridge/SdkNative.java @@ -61,6 +61,10 @@ public static native void oauthBuild(String clientId, int callbackPort, public static native void freeContentContext(long context); + public static native void contentContextTopicsMine(long context, Object opts, AsyncCallback callback); + + public static native void contentContextCreateTopic(long context, Object opts, AsyncCallback callback); + public static native void contentContextTopics(long context, String symbol, AsyncCallback callback); public static native void contentContextNews(long context, String symbol, AsyncCallback callback); diff --git a/java/javasrc/src/main/java/com/longbridge/content/ContentContext.java b/java/javasrc/src/main/java/com/longbridge/content/ContentContext.java index a70b4dcbc..4a002d1a6 100644 --- a/java/javasrc/src/main/java/com/longbridge/content/ContentContext.java +++ b/java/javasrc/src/main/java/com/longbridge/content/ContentContext.java @@ -27,6 +27,34 @@ public void close() throws Exception { SdkNative.freeContentContext(raw); } + /** + * Get topics created by the current authenticated user + * + * @param opts Query options (page, size, topicType); may be null + * @return A Future representing the result of the operation + * @throws OpenApiException If an error occurs + */ + public CompletableFuture getTopicsMine(ListMyTopicsOptions opts) + throws OpenApiException { + return AsyncCallback.executeTask((callback) -> { + SdkNative.contentContextTopicsMine(raw, opts, callback); + }); + } + + /** + * Create a new topic + * + * @param opts Create topic options + * @return A Future representing the result of the operation + * @throws OpenApiException If an error occurs + */ + public CompletableFuture createTopic(CreateTopicOptions opts) + throws OpenApiException { + return AsyncCallback.executeTask((callback) -> { + SdkNative.contentContextCreateTopic(raw, opts, callback); + }); + } + /** * Get discussion topics list * diff --git a/java/javasrc/src/main/java/com/longbridge/content/CreateTopicOptions.java b/java/javasrc/src/main/java/com/longbridge/content/CreateTopicOptions.java new file mode 100644 index 000000000..21555771a --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/content/CreateTopicOptions.java @@ -0,0 +1,69 @@ +package com.longbridge.content; + +/** + * Options for creating a topic + */ +@SuppressWarnings("unused") +public class CreateTopicOptions { + private String title; + private String body; + private String topicType; + private String[] tickers; + private String[] hashtags; + private Integer license; + + /** + * Constructs a create-topic request. + * + * @param title topic title (required) + * @param body topic body in Markdown format (required) + */ + public CreateTopicOptions(String title, String body) { + this.title = title; + this.body = body; + } + + /** + * Sets the content type: "article" (long-form) or "post" (short post, default). + * + * @param topicType content type + * @return this instance for chaining + */ + public CreateTopicOptions setTopicType(String topicType) { + this.topicType = topicType; + return this; + } + + /** + * Sets the related stock tickers, format: {symbol}.{market}, max 10. + * + * @param tickers stock tickers + * @return this instance for chaining + */ + public CreateTopicOptions setTickers(String[] tickers) { + this.tickers = tickers; + return this; + } + + /** + * Sets the hashtag names, max 5. + * + * @param hashtags hashtag names + * @return this instance for chaining + */ + public CreateTopicOptions setHashtags(String[] hashtags) { + this.hashtags = hashtags; + return this; + } + + /** + * Sets the license: 0=none (default), 1=original, 2=non-original. + * + * @param license license value + * @return this instance for chaining + */ + public CreateTopicOptions setLicense(int license) { + this.license = license; + return this; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/content/ListMyTopicsOptions.java b/java/javasrc/src/main/java/com/longbridge/content/ListMyTopicsOptions.java new file mode 100644 index 000000000..4de859767 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/content/ListMyTopicsOptions.java @@ -0,0 +1,44 @@ +package com.longbridge.content; + +/** + * Options for listing topics created by the current authenticated user + */ +@SuppressWarnings("unused") +public class ListMyTopicsOptions { + private Integer page; + private Integer size; + private String topicType; + + /** + * Sets the page number (default 1). + * + * @param page page number + * @return this instance for chaining + */ + public ListMyTopicsOptions setPage(int page) { + this.page = page; + return this; + } + + /** + * Sets the number of records per page, range 1~500 (default 50). + * + * @param size records per page + * @return this instance for chaining + */ + public ListMyTopicsOptions setSize(int size) { + this.size = size; + return this; + } + + /** + * Filters by topic type: "article" or "post". Leave null to return all. + * + * @param topicType topic type filter + * @return this instance for chaining + */ + public ListMyTopicsOptions setTopicType(String topicType) { + this.topicType = topicType; + return this; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/content/OwnedTopic.java b/java/javasrc/src/main/java/com/longbridge/content/OwnedTopic.java new file mode 100644 index 000000000..769164427 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/content/OwnedTopic.java @@ -0,0 +1,83 @@ +package com.longbridge.content; + +import java.time.OffsetDateTime; + +/** + * Topic created by the current authenticated user + */ +public class OwnedTopic { + private String id; + private String title; + private String description; + private String body; + private TopicAuthor author; + private String[] tickers; + private String[] hashtags; + private TopicImage[] images; + private int likesCount; + private int commentsCount; + private int viewsCount; + private int sharesCount; + private String topicType; + private int license; + private String detailUrl; + private OffsetDateTime createdAt; + private OffsetDateTime updatedAt; + + /** Returns the topic ID. */ + public String getId() { return id; } + + /** Returns the title. */ + public String getTitle() { return title; } + + /** Returns the plain text excerpt. */ + public String getDescription() { return description; } + + /** Returns the Markdown body. */ + public String getBody() { return body; } + + /** Returns the author. */ + public TopicAuthor getAuthor() { return author; } + + /** Returns the related stock tickers. */ + public String[] getTickers() { return tickers; } + + /** Returns the hashtag names. */ + public String[] getHashtags() { return hashtags; } + + /** Returns the images. */ + public TopicImage[] getImages() { return images; } + + /** Returns the likes count. */ + public int getLikesCount() { return likesCount; } + + /** Returns the comments count. */ + public int getCommentsCount() { return commentsCount; } + + /** Returns the views count. */ + public int getViewsCount() { return viewsCount; } + + /** Returns the shares count. */ + public int getSharesCount() { return sharesCount; } + + /** Returns the content type: "article" or "post". */ + public String getTopicType() { return topicType; } + + /** Returns the license: 0=none, 1=original, 2=non-original. */ + public int getLicense() { return license; } + + /** Returns the URL to the full topic page. */ + public String getDetailUrl() { return detailUrl; } + + /** Returns the created time. */ + public OffsetDateTime getCreatedAt() { return createdAt; } + + /** Returns the updated time. */ + public OffsetDateTime getUpdatedAt() { return updatedAt; } + + @Override + public String toString() { + return "OwnedTopic [id=" + id + ", title=" + title + ", topicType=" + topicType + + ", createdAt=" + createdAt + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/content/TopicAuthor.java b/java/javasrc/src/main/java/com/longbridge/content/TopicAuthor.java new file mode 100644 index 000000000..ec8fb2d98 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/content/TopicAuthor.java @@ -0,0 +1,42 @@ +package com.longbridge.content; + +/** + * Topic author + */ +public class TopicAuthor { + private String memberId; + private String name; + private String avatar; + + /** + * Returns the member ID. + * + * @return the member ID + */ + public String getMemberId() { + return memberId; + } + + /** + * Returns the display name. + * + * @return the display name + */ + public String getName() { + return name; + } + + /** + * Returns the avatar URL. + * + * @return the avatar URL + */ + public String getAvatar() { + return avatar; + } + + @Override + public String toString() { + return "TopicAuthor [memberId=" + memberId + ", name=" + name + ", avatar=" + avatar + "]"; + } +} diff --git a/java/javasrc/src/main/java/com/longbridge/content/TopicImage.java b/java/javasrc/src/main/java/com/longbridge/content/TopicImage.java new file mode 100644 index 000000000..fdb037c93 --- /dev/null +++ b/java/javasrc/src/main/java/com/longbridge/content/TopicImage.java @@ -0,0 +1,42 @@ +package com.longbridge.content; + +/** + * Topic image + */ +public class TopicImage { + private String url; + private String sm; + private String lg; + + /** + * Returns the original image URL. + * + * @return the original image URL + */ + public String getUrl() { + return url; + } + + /** + * Returns the small thumbnail URL. + * + * @return the small thumbnail URL + */ + public String getSm() { + return sm; + } + + /** + * Returns the large image URL. + * + * @return the large image URL + */ + public String getLg() { + return lg; + } + + @Override + public String toString() { + return "TopicImage [url=" + url + ", sm=" + sm + ", lg=" + lg + "]"; + } +} diff --git a/java/javasrc/src/main/resources/native/mac/liblongbridge_java.dylib b/java/javasrc/src/main/resources/native/mac/liblongbridge_java.dylib new file mode 100755 index 000000000..94f286c61 Binary files /dev/null and b/java/javasrc/src/main/resources/native/mac/liblongbridge_java.dylib differ diff --git a/java/src/content_context.rs b/java/src/content_context.rs index a52c75362..f5aade65a 100644 --- a/java/src/content_context.rs +++ b/java/src/content_context.rs @@ -4,12 +4,15 @@ use jni::{ JNIEnv, objects::{JClass, JObject}, }; -use longbridge::{Config, content::ContentContext}; +use longbridge::{ + Config, + content::{ContentContext, CreateTopicOptions, ListMyTopicsOptions}, +}; use crate::{ async_util, error::jni_result, - types::{FromJValue, ObjectArray}, + types::{FromJValue, JavaInteger, ObjectArray, get_field}, }; struct ContextObj { @@ -38,6 +41,68 @@ pub unsafe extern "system" fn Java_com_longbridge_SdkNative_freeContentContext( let _ = Box::from_raw(ctx as *mut ContextObj); } +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_contentContextTopicsMine( + mut env: JNIEnv, + _class: JClass, + context: i64, + opts: JObject, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let page: Option = get_field(env, &opts, "page")?; + let size: Option = get_field(env, &opts, "size")?; + let topic_type: Option = get_field(env, &opts, "topicType")?; + async_util::execute(env, callback, async move { + Ok(ObjectArray( + context + .ctx + .topics_mine(ListMyTopicsOptions { + page: page.map(i32::from), + size: size.map(i32::from), + topic_type, + }) + .await?, + )) + })?; + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "system" fn Java_com_longbridge_SdkNative_contentContextCreateTopic( + mut env: JNIEnv, + _class: JClass, + context: i64, + opts: JObject, + callback: JObject, +) { + jni_result(&mut env, (), |env| { + let context = &*(context as *const ContextObj); + let title: String = get_field(env, &opts, "title")?; + let body: String = get_field(env, &opts, "body")?; + let topic_type: Option = get_field(env, &opts, "topicType")?; + let tickers: Option> = get_field(env, &opts, "tickers")?; + let hashtags: Option> = get_field(env, &opts, "hashtags")?; + let license: Option = get_field(env, &opts, "license")?; + async_util::execute(env, callback, async move { + Ok(context + .ctx + .create_topic(CreateTopicOptions { + title, + body, + topic_type, + tickers: tickers.map(|a| a.0), + hashtags: hashtags.map(|a| a.0), + license: license.map(i32::from), + }) + .await?) + })?; + Ok(()) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "system" fn Java_com_longbridge_SdkNative_contentContextTopics( mut env: JNIEnv, diff --git a/java/src/init.rs b/java/src/init.rs index 76afa91ec..e48f9acca 100644 --- a/java/src/init.rs +++ b/java/src/init.rs @@ -177,6 +177,9 @@ pub extern "system" fn Java_com_longbridge_SdkNative_init<'a>( longbridge::trade::OrderDetail, longbridge::trade::EstimateMaxPurchaseQuantityResponse, longbridge::content::TopicItem, - longbridge::content::NewsItem + longbridge::content::NewsItem, + longbridge::content::TopicAuthor, + longbridge::content::TopicImage, + longbridge::content::OwnedTopic ); } diff --git a/java/src/types/classes.rs b/java/src/types/classes.rs index ce0bf48d8..8f8551b69 100644 --- a/java/src/types/classes.rs +++ b/java/src/types/classes.rs @@ -1009,3 +1009,42 @@ impl_java_class!( shares_count ] ); + +impl_java_class!( + "com/longbridge/content/TopicAuthor", + longbridge::content::TopicAuthor, + [member_id, name, avatar] +); + +impl_java_class!( + "com/longbridge/content/TopicImage", + longbridge::content::TopicImage, + [url, sm, lg] +); + +impl_java_class!( + "com/longbridge/content/OwnedTopic", + longbridge::content::OwnedTopic, + [ + id, + title, + description, + body, + author, + #[java(objarray)] + tickers, + #[java(objarray)] + hashtags, + #[java(objarray)] + images, + likes_count, + comments_count, + views_count, + shares_count, + topic_type, + license, + detail_url, + created_at, + updated_at + ] +); diff --git a/nodejs/index.d.ts b/nodejs/index.d.ts index 9cebfc3c8..6be0948b7 100644 --- a/nodejs/index.d.ts +++ b/nodejs/index.d.ts @@ -221,6 +221,10 @@ export declare class Config { export declare class ContentContext { /** Create a new `ContentContext` */ static new(config: Config): ContentContext + /** Get topics created by the current authenticated user */ + topicsMine(req?: ListMyTopicsRequest | undefined | null): Promise> + /** Create a new topic */ + createTopic(req: CreateTopicRequest): Promise /** Get discussion topics list */ topics(symbol: string): Promise> /** Get news list */ @@ -951,6 +955,46 @@ export declare class OrderHistoryDetail { get time(): Date } +/** My topic item (topic created by the current authenticated user) */ +export declare class OwnedTopic { + toString(): string + toJSON(): any + /** Topic ID */ + get id(): string + /** Title */ + get title(): string + /** Plain text excerpt */ + get description(): string + /** Markdown body */ + get body(): string + /** Author */ + get author(): TopicAuthor + /** Related stock tickers */ + get tickers(): Array + /** Hashtag names */ + get hashtags(): Array + /** Images */ + get images(): Array + /** Likes count */ + get likesCount(): number + /** Comments count */ + get commentsCount(): number + /** Views count */ + get viewsCount(): number + /** Shares count */ + get sharesCount(): number + /** Content type: "article" or "post" */ + get topicType(): string + /** License: 0=none, 1=original, 2=non-original */ + get license(): number + /** URL to the full topic page */ + get detailUrl(): string + /** Created time */ + get createdAt(): Date + /** Updated time */ + get updatedAt(): Date +} + /** Participant info */ export declare class ParticipantInfo { toString(): string @@ -2046,6 +2090,30 @@ export declare class Time { toJSON(): any } +/** Topic author */ +export declare class TopicAuthor { + toString(): string + toJSON(): any + /** Member ID */ + get memberId(): string + /** Display name */ + get name(): string + /** Avatar URL */ + get avatar(): string +} + +/** Topic image */ +export declare class TopicImage { + toString(): string + toJSON(): any + /** Original image URL */ + get url(): string + /** Small thumbnail URL */ + get sm(): string + /** Large image URL */ + get lg(): string +} + /** Topic item */ export declare class TopicItem { toString(): string @@ -2685,6 +2753,22 @@ export declare const enum CommissionFreeStatus { Ready = 4 } +/** Options for creating a topic */ +export interface CreateTopicRequest { + /** Topic title (required) */ + title: string + /** Topic body in Markdown format (required) */ + body: string + /** Content type: "article" (long-form) or "post" (short post, default) */ + topicType?: string + /** Related stock tickers, format: {symbol}.{market}, max 10 */ + tickers?: Array + /** Hashtag names, max 5 */ + hashtags?: Array + /** License: 0=none (default), 1=original, 2=non-original */ + license?: number +} + /** An request to create a watchlist group */ export interface CreateWatchlistGroup { /** Group name */ @@ -2871,6 +2955,16 @@ export declare const enum Language { EN = 2 } +/** Options for listing topics created by the current authenticated user */ +export interface ListMyTopicsRequest { + /** Page number (default 1) */ + page?: number + /** Records per page, range 1~500 (default 50) */ + size?: number + /** Filter by topic type: "article" or "post"; empty returns all */ + topicType?: string +} + export declare const enum Market { /** Unknown */ Unknown = 0, diff --git a/nodejs/index.js b/nodejs/index.js index c83e10484..d868d85e0 100644 --- a/nodejs/index.js +++ b/nodejs/index.js @@ -614,6 +614,7 @@ module.exports.OrderChargeFee = nativeBinding.OrderChargeFee module.exports.OrderChargeItem = nativeBinding.OrderChargeItem module.exports.OrderDetail = nativeBinding.OrderDetail module.exports.OrderHistoryDetail = nativeBinding.OrderHistoryDetail +module.exports.OwnedTopic = nativeBinding.OwnedTopic module.exports.ParticipantInfo = nativeBinding.ParticipantInfo module.exports.PrePostQuote = nativeBinding.PrePostQuote module.exports.PushBrokers = nativeBinding.PushBrokers @@ -643,6 +644,8 @@ module.exports.StrikePriceInfo = nativeBinding.StrikePriceInfo module.exports.SubmitOrderResponse = nativeBinding.SubmitOrderResponse module.exports.Subscription = nativeBinding.Subscription module.exports.Time = nativeBinding.Time +module.exports.TopicAuthor = nativeBinding.TopicAuthor +module.exports.TopicImage = nativeBinding.TopicImage module.exports.TopicItem = nativeBinding.TopicItem module.exports.Trade = nativeBinding.Trade module.exports.TradeContext = nativeBinding.TradeContext diff --git a/nodejs/src/content/context.rs b/nodejs/src/content/context.rs index 49ccff33c..03d528514 100644 --- a/nodejs/src/content/context.rs +++ b/nodejs/src/content/context.rs @@ -4,7 +4,10 @@ use napi::Result; use crate::{ config::Config, - content::types::{NewsItem, TopicItem}, + content::{ + requests::{CreateTopicRequest, ListMyTopicsRequest}, + types::{NewsItem, OwnedTopic, TopicItem}, + }, error::ErrorNewType, }; @@ -25,6 +28,28 @@ impl ContentContext { } } + /// Get topics created by the current authenticated user + #[napi] + pub async fn topics_mine(&self, req: Option) -> Result> { + self.ctx + .topics_mine(req.unwrap_or_default().into()) + .await + .map_err(ErrorNewType)? + .into_iter() + .map(TryInto::try_into) + .collect() + } + + /// Create a new topic + #[napi] + pub async fn create_topic(&self, req: CreateTopicRequest) -> Result { + self.ctx + .create_topic(req.into()) + .await + .map_err(ErrorNewType)? + .try_into() + } + /// Get discussion topics list #[napi] pub async fn topics(&self, symbol: String) -> Result> { diff --git a/nodejs/src/content/mod.rs b/nodejs/src/content/mod.rs index 0561d4d5a..979fe16d6 100644 --- a/nodejs/src/content/mod.rs +++ b/nodejs/src/content/mod.rs @@ -1,2 +1,3 @@ pub mod context; +pub mod requests; pub mod types; diff --git a/nodejs/src/content/requests.rs b/nodejs/src/content/requests.rs new file mode 100644 index 000000000..d609a0d57 --- /dev/null +++ b/nodejs/src/content/requests.rs @@ -0,0 +1,69 @@ +use longbridge::content::{CreateTopicOptions, ListMyTopicsOptions}; + +/// Options for listing topics created by the current authenticated user +#[napi_derive::napi(object)] +#[derive(Debug, Default)] +pub struct ListMyTopicsRequest { + /// Page number (default 1) + pub page: Option, + /// Records per page, range 1~500 (default 50) + pub size: Option, + /// Filter by topic type: "article" or "post"; empty returns all + pub topic_type: Option, +} + +impl From for ListMyTopicsOptions { + fn from( + ListMyTopicsRequest { + page, + size, + topic_type, + }: ListMyTopicsRequest, + ) -> Self { + Self { + page, + size, + topic_type, + } + } +} + +/// Options for creating a topic +#[napi_derive::napi(object)] +#[derive(Debug)] +pub struct CreateTopicRequest { + /// Topic title (required) + pub title: String, + /// Topic body in Markdown format (required) + pub body: String, + /// Content type: "article" (long-form) or "post" (short post, default) + pub topic_type: Option, + /// Related stock tickers, format: {symbol}.{market}, max 10 + pub tickers: Option>, + /// Hashtag names, max 5 + pub hashtags: Option>, + /// License: 0=none (default), 1=original, 2=non-original + pub license: Option, +} + +impl From for CreateTopicOptions { + fn from( + CreateTopicRequest { + title, + body, + topic_type, + tickers, + hashtags, + license, + }: CreateTopicRequest, + ) -> Self { + Self { + title, + body, + topic_type, + tickers, + hashtags, + license, + } + } +} diff --git a/nodejs/src/content/types.rs b/nodejs/src/content/types.rs index 5b9489fac..cc91c46d6 100644 --- a/nodejs/src/content/types.rs +++ b/nodejs/src/content/types.rs @@ -1,6 +1,78 @@ use chrono::{DateTime, Utc}; use longbridge_nodejs_macros::JsObject; +/// Topic author +#[napi_derive::napi] +#[derive(Debug, JsObject, Clone)] +#[js(remote = "longbridge::content::TopicAuthor")] +pub struct TopicAuthor { + /// Member ID + member_id: String, + /// Display name + name: String, + /// Avatar URL + avatar: String, +} + +/// Topic image +#[napi_derive::napi] +#[derive(Debug, JsObject, Clone)] +#[js(remote = "longbridge::content::TopicImage")] +pub struct TopicImage { + /// Original image URL + url: String, + /// Small thumbnail URL + sm: String, + /// Large image URL + lg: String, +} + +/// My topic item (topic created by the current authenticated user) +#[napi_derive::napi] +#[derive(Debug, JsObject, Clone)] +#[js(remote = "longbridge::content::OwnedTopic")] +pub struct OwnedTopic { + /// Topic ID + id: String, + /// Title + title: String, + /// Plain text excerpt + description: String, + /// Markdown body + body: String, + /// Author + author: TopicAuthor, + /// Related stock tickers + #[js(array)] + tickers: Vec, + /// Hashtag names + #[js(array)] + hashtags: Vec, + /// Images + #[js(array)] + images: Vec, + /// Likes count + likes_count: i32, + /// Comments count + comments_count: i32, + /// Views count + views_count: i32, + /// Shares count + shares_count: i32, + /// Content type: "article" or "post" + topic_type: String, + /// License: 0=none, 1=original, 2=non-original + license: i32, + /// URL to the full topic page + detail_url: String, + /// Created time + #[js(datetime)] + created_at: DateTime, + /// Updated time + #[js(datetime)] + updated_at: DateTime, +} + /// Topic item #[napi_derive::napi] #[derive(Debug, JsObject, Clone)] diff --git a/python/src/content/context.rs b/python/src/content/context.rs index d258bba67..5a9e54c23 100644 --- a/python/src/content/context.rs +++ b/python/src/content/context.rs @@ -1,11 +1,14 @@ use std::sync::Arc; -use longbridge::blocking::ContentContextSync; +use longbridge::{ + blocking::ContentContextSync, + content::{CreateTopicOptions, ListMyTopicsOptions}, +}; use pyo3::prelude::*; use crate::{ config::Config, - content::types::{NewsItem, TopicItem}, + content::types::{NewsItem, OwnedTopic, TopicItem}, error::ErrorNewType, }; @@ -23,6 +26,50 @@ impl ContentContext { }) } + /// Get topics created by the current authenticated user + #[pyo3(signature = (page = None, size = None, topic_type = None))] + pub fn topics_mine( + &self, + page: Option, + size: Option, + topic_type: Option, + ) -> PyResult> { + self.ctx + .topics_mine(ListMyTopicsOptions { + page, + size, + topic_type, + }) + .map_err(ErrorNewType)? + .into_iter() + .map(TryInto::try_into) + .collect() + } + + /// Create a new topic + #[pyo3(signature = (title, body, topic_type = None, tickers = None, hashtags = None, license = None))] + pub fn create_topic( + &self, + title: String, + body: String, + topic_type: Option, + tickers: Option>, + hashtags: Option>, + license: Option, + ) -> PyResult { + self.ctx + .create_topic(CreateTopicOptions { + title, + body, + topic_type, + tickers, + hashtags, + license, + }) + .map_err(ErrorNewType)? + .try_into() + } + /// Get discussion topics list pub fn topics(&self, symbol: String) -> PyResult> { self.ctx diff --git a/python/src/content/context_async.rs b/python/src/content/context_async.rs index 25f39be58..651a567a6 100644 --- a/python/src/content/context_async.rs +++ b/python/src/content/context_async.rs @@ -1,11 +1,11 @@ use std::sync::Arc; -use longbridge::content::ContentContext; +use longbridge::content::{ContentContext, CreateTopicOptions, ListMyTopicsOptions}; use pyo3::{prelude::*, types::PyType}; use crate::{ config::Config, - content::types::{NewsItem, TopicItem}, + content::types::{NewsItem, OwnedTopic, TopicItem}, error::ErrorNewType, }; @@ -25,6 +25,62 @@ impl AsyncContentContext { } } + /// Get topics created by the current authenticated user. Returns awaitable. + #[pyo3(signature = (page = None, size = None, topic_type = None))] + fn topics_mine( + &self, + py: Python<'_>, + page: Option, + size: Option, + topic_type: Option, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let v = ctx + .topics_mine(ListMyTopicsOptions { + page, + size, + topic_type, + }) + .await + .map_err(ErrorNewType)?; + v.into_iter() + .map(|x| -> PyResult { x.try_into() }) + .collect::>>() + }) + .map(|b| b.unbind()) + } + + /// Create a new topic. Returns awaitable. + #[pyo3(signature = (title, body, topic_type = None, tickers = None, hashtags = None, license = None))] + fn create_topic( + &self, + py: Python<'_>, + title: String, + body: String, + topic_type: Option, + tickers: Option>, + hashtags: Option>, + license: Option, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let resp = ctx + .create_topic(CreateTopicOptions { + title, + body, + topic_type, + tickers, + hashtags, + license, + }) + .await + .map_err(ErrorNewType)?; + OwnedTopic::try_from(resp) + }) + .map(|b| b.unbind()) + } + /// Get discussion topics list. Returns awaitable. fn topics(&self, py: Python<'_>, symbol: String) -> PyResult> { let ctx = self.ctx.clone(); diff --git a/python/src/content/mod.rs b/python/src/content/mod.rs index ce391b31f..7c8b0c8f9 100644 --- a/python/src/content/mod.rs +++ b/python/src/content/mod.rs @@ -7,6 +7,9 @@ use pyo3::prelude::*; pub(crate) fn register_types(parent: &Bound) -> PyResult<()> { parent.add_class::()?; parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; parent.add_class::()?; parent.add_class::()?; Ok(()) diff --git a/python/src/content/types.rs b/python/src/content/types.rs index cb186115e..3bcd19412 100644 --- a/python/src/content/types.rs +++ b/python/src/content/types.rs @@ -3,6 +3,76 @@ use pyo3::prelude::*; use crate::time::PyOffsetDateTimeWrapper; +/// Topic author +#[pyclass(skip_from_py_object)] +#[derive(Debug, PyObject, Clone)] +#[py(remote = "longbridge::content::TopicAuthor")] +pub(crate) struct TopicAuthor { + /// Member ID + member_id: String, + /// Display name + name: String, + /// Avatar URL + avatar: String, +} + +/// Topic image +#[pyclass(skip_from_py_object)] +#[derive(Debug, PyObject, Clone)] +#[py(remote = "longbridge::content::TopicImage")] +pub(crate) struct TopicImage { + /// Original image URL + url: String, + /// Small thumbnail URL + sm: String, + /// Large image URL + lg: String, +} + +/// My topic item (topic created by the current authenticated user) +#[pyclass(skip_from_py_object)] +#[derive(Debug, PyObject, Clone)] +#[py(remote = "longbridge::content::OwnedTopic")] +pub(crate) struct OwnedTopic { + /// Topic ID + id: String, + /// Title + title: String, + /// Plain text excerpt + description: String, + /// Markdown body + body: String, + /// Author + author: TopicAuthor, + /// Related stock tickers + #[py(array)] + tickers: Vec, + /// Hashtag names + #[py(array)] + hashtags: Vec, + /// Images + #[py(array)] + images: Vec, + /// Likes count + likes_count: i32, + /// Comments count + comments_count: i32, + /// Views count + views_count: i32, + /// Shares count + shares_count: i32, + /// Content type: "article" or "post" + topic_type: String, + /// License: 0=none, 1=original, 2=non-original + license: i32, + /// URL to the full topic page + detail_url: String, + /// Created time + created_at: PyOffsetDateTimeWrapper, + /// Updated time + updated_at: PyOffsetDateTimeWrapper, +} + /// Topic item #[pyclass(skip_from_py_object)] #[derive(Debug, PyObject, Clone)] diff --git a/rust/src/blocking/content.rs b/rust/src/blocking/content.rs index 1d469a8ad..2fb0a94b3 100644 --- a/rust/src/blocking/content.rs +++ b/rust/src/blocking/content.rs @@ -5,7 +5,9 @@ use tokio::sync::mpsc; use crate::{ Config, Result, blocking::runtime::BlockingRuntime, - content::{ContentContext, NewsItem, TopicItem}, + content::{ + ContentContext, CreateTopicOptions, ListMyTopicsOptions, NewsItem, OwnedTopic, TopicItem, + }, }; /// Blocking content context @@ -28,6 +30,18 @@ impl ContentContextSync { Ok(Self { rt }) } + /// Get topics created by the current authenticated user + pub fn topics_mine(&self, opts: ListMyTopicsOptions) -> Result> { + self.rt + .call(move |ctx| async move { ctx.topics_mine(opts).await }) + } + + /// Create a new topic + pub fn create_topic(&self, opts: CreateTopicOptions) -> Result { + self.rt + .call(move |ctx| async move { ctx.create_topic(opts).await }) + } + /// Get discussion topics list pub fn topics(&self, symbol: impl Into) -> Result> { let symbol = symbol.into(); diff --git a/rust/src/content/context.rs b/rust/src/content/context.rs index ac50da38c..fbbc0e66d 100644 --- a/rust/src/content/context.rs +++ b/rust/src/content/context.rs @@ -3,7 +3,7 @@ use std::sync::Arc; use longbridge_httpcli::{HttpClient, Json, Method}; use serde::Deserialize; -use super::types::{NewsItem, TopicItem}; +use super::types::{CreateTopicOptions, ListMyTopicsOptions, NewsItem, OwnedTopic, TopicItem}; use crate::{Config, Result}; struct InnerContentContext { @@ -22,6 +22,48 @@ impl ContentContext { })) } + /// Get topics created by the current authenticated user + /// + /// Path: GET /v1/content/topics/mine + pub async fn topics_mine(&self, opts: ListMyTopicsOptions) -> Result> { + #[derive(Debug, Deserialize)] + struct Response { + items: Vec, + } + + Ok(self + .0 + .http_cli + .request(Method::GET, "/v1/content/topics/mine") + .query_params(opts) + .response::>() + .send() + .await? + .0 + .items) + } + + /// Create a new topic + /// + /// Path: POST /v1/content/topics + pub async fn create_topic(&self, opts: CreateTopicOptions) -> Result { + #[derive(Debug, Deserialize)] + struct Response { + item: OwnedTopic, + } + + Ok(self + .0 + .http_cli + .request(Method::POST, "/v1/content/topics") + .body(Json(opts)) + .response::>() + .send() + .await? + .0 + .item) + } + /// Get discussion topics list pub async fn topics(&self, symbol: impl Into) -> Result> { #[derive(Debug, Deserialize)] diff --git a/rust/src/content/mod.rs b/rust/src/content/mod.rs index fdd32b94e..829b13428 100644 --- a/rust/src/content/mod.rs +++ b/rust/src/content/mod.rs @@ -4,4 +4,7 @@ mod context; mod types; pub use context::ContentContext; -pub use types::{NewsItem, TopicItem}; +pub use types::{ + CreateTopicOptions, ListMyTopicsOptions, NewsItem, OwnedTopic, TopicAuthor, TopicImage, + TopicItem, +}; diff --git a/rust/src/content/types.rs b/rust/src/content/types.rs index 11e6320b5..9562dd287 100644 --- a/rust/src/content/types.rs +++ b/rust/src/content/types.rs @@ -3,6 +3,124 @@ use time::OffsetDateTime; use crate::serde_utils; +/// Topic author +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopicAuthor { + /// Member ID + #[serde(default)] + pub member_id: String, + /// Display name + #[serde(default)] + pub name: String, + /// Avatar URL + #[serde(default)] + pub avatar: String, +} + +/// Topic image +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopicImage { + /// Original image URL + #[serde(default)] + pub url: String, + /// Small thumbnail URL + #[serde(default)] + pub sm: String, + /// Large image URL + #[serde(default)] + pub lg: String, +} + +/// My topic item (topic created by the current authenticated user) +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct OwnedTopic { + /// Topic ID + pub id: String, + /// Title + #[serde(default)] + pub title: String, + /// Plain text excerpt + #[serde(default)] + pub description: String, + /// Markdown body + #[serde(default)] + pub body: String, + /// Author + pub author: TopicAuthor, + /// Related stock tickers, format: {symbol}.{market} + #[serde(default)] + pub tickers: Vec, + /// Hashtag names + #[serde(default)] + pub hashtags: Vec, + /// Images + #[serde(default)] + pub images: Vec, + /// Likes count + pub likes_count: i32, + /// Comments count + pub comments_count: i32, + /// Views count + pub views_count: i32, + /// Shares count + pub shares_count: i32, + /// Content type: "article" or "post" + #[serde(default)] + pub topic_type: String, + /// License: 0=none, 1=original, 2=non-original + pub license: i32, + /// URL to the full topic page + #[serde(default)] + pub detail_url: String, + /// Created time + #[serde( + serialize_with = "time::serde::rfc3339::serialize", + deserialize_with = "serde_utils::timestamp::deserialize" + )] + pub created_at: OffsetDateTime, + /// Updated time + #[serde( + serialize_with = "time::serde::rfc3339::serialize", + deserialize_with = "serde_utils::timestamp::deserialize" + )] + pub updated_at: OffsetDateTime, +} + +/// Options for listing topics created by the current authenticated user +#[derive(Debug, Default, Clone, Serialize)] +pub struct ListMyTopicsOptions { + /// Page number (default 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + /// Records per page, range 1~500 (default 50) + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, + /// Filter by topic type: "article" or "post"; empty returns all + #[serde(skip_serializing_if = "Option::is_none")] + pub topic_type: Option, +} + +/// Options for creating a topic +#[derive(Debug, Clone, Serialize)] +pub struct CreateTopicOptions { + /// Topic title (required) + pub title: String, + /// Topic body in Markdown format (required) + pub body: String, + /// Content type: "article" (long-form) or "post" (short post, default) + #[serde(skip_serializing_if = "Option::is_none")] + pub topic_type: Option, + /// Related stock tickers, format: {symbol}.{market}, max 10 + #[serde(skip_serializing_if = "Option::is_none")] + pub tickers: Option>, + /// Hashtag names, max 5 + #[serde(skip_serializing_if = "Option::is_none")] + pub hashtags: Option>, + /// License: 0=none (default), 1=original, 2=non-original + #[serde(skip_serializing_if = "Option::is_none")] + pub license: Option, +} + /// Topic item #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TopicItem {