diff --git a/python/src/content/context.rs b/python/src/content/context.rs index 579016364..6c9b37f5d 100644 --- a/python/src/content/context.rs +++ b/python/src/content/context.rs @@ -2,13 +2,13 @@ use std::sync::Arc; use longbridge::{ blocking::ContentContextSync, - content::{CreateTopicOptions, MyTopicsOptions}, + content::{CreateReplyOptions, CreateTopicOptions, ListTopicRepliesOptions, MyTopicsOptions}, }; use pyo3::prelude::*; use crate::{ config::Config, - content::types::{NewsItem, OwnedTopic, TopicItem}, + content::types::{NewsItem, OwnedTopic, TopicItem, TopicReply}, error::ErrorNewType, }; @@ -46,7 +46,9 @@ impl ContentContext { .collect() } - /// Create a new topic + /// Create a new community topic. + /// + /// See: #[pyo3(signature = (title, body, topic_type = None, tickers = None, hashtags = None))] pub fn create_topic( &self, @@ -87,4 +89,41 @@ impl ContentContext { .map(TryInto::try_into) .collect() } + + /// Get full details of a topic by its ID + pub fn topic_detail(&self, id: String) -> PyResult { + self.ctx.topic_detail(id).map_err(ErrorNewType)?.try_into() + } + + /// List replies on a topic + #[pyo3(signature = (topic_id, page = None, size = None))] + pub fn list_topic_replies( + &self, + topic_id: String, + page: Option, + size: Option, + ) -> PyResult> { + self.ctx + .list_topic_replies(topic_id, ListTopicRepliesOptions { page, size }) + .map_err(ErrorNewType)? + .into_iter() + .map(TryInto::try_into) + .collect() + } + + /// Post a reply to a community topic. + /// + /// See: + #[pyo3(signature = (topic_id, body, reply_to_id = None))] + pub fn create_topic_reply( + &self, + topic_id: String, + body: String, + reply_to_id: Option, + ) -> PyResult { + self.ctx + .create_topic_reply(topic_id, CreateReplyOptions { body, reply_to_id }) + .map_err(ErrorNewType)? + .try_into() + } } diff --git a/python/src/content/context_async.rs b/python/src/content/context_async.rs index ca390a449..f24b51576 100644 --- a/python/src/content/context_async.rs +++ b/python/src/content/context_async.rs @@ -1,11 +1,14 @@ use std::sync::Arc; -use longbridge::content::{ContentContext, CreateTopicOptions, MyTopicsOptions}; +use longbridge::content::{ + ContentContext, CreateReplyOptions, CreateTopicOptions, ListTopicRepliesOptions, + MyTopicsOptions, +}; use pyo3::{prelude::*, types::PyType}; use crate::{ config::Config, - content::types::{NewsItem, OwnedTopic, TopicItem}, + content::types::{NewsItem, OwnedTopic, TopicItem, TopicReply}, error::ErrorNewType, }; @@ -51,7 +54,9 @@ impl AsyncContentContext { .map(|b| b.unbind()) } - /// Create a new topic. Returns awaitable. + /// Create a new community topic. Returns awaitable. + /// + /// See: #[pyo3(signature = (title, body, topic_type = None, tickers = None, hashtags = None))] fn create_topic( &self, @@ -101,4 +106,58 @@ impl AsyncContentContext { }) .map(|b| b.unbind()) } + + /// Get full details of a topic by its ID. Returns awaitable. + fn topic_detail(&self, py: Python<'_>, id: String) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let v = ctx.topic_detail(id).await.map_err(ErrorNewType)?; + OwnedTopic::try_from(v) + }) + .map(|b| b.unbind()) + } + + /// List replies on a topic. Returns awaitable. + #[pyo3(signature = (topic_id, page = None, size = None))] + fn list_topic_replies( + &self, + py: Python<'_>, + topic_id: String, + page: Option, + size: Option, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let v = ctx + .list_topic_replies(topic_id, ListTopicRepliesOptions { page, size }) + .await + .map_err(ErrorNewType)?; + v.into_iter() + .map(|x| -> PyResult { x.try_into() }) + .collect::>>() + }) + .map(|b| b.unbind()) + } + + /// Post a reply to a community topic. Returns awaitable. + /// + /// See: + #[pyo3(signature = (topic_id, body, reply_to_id = None))] + fn create_topic_reply( + &self, + py: Python<'_>, + topic_id: String, + body: String, + reply_to_id: Option, + ) -> PyResult> { + let ctx = self.ctx.clone(); + pyo3_async_runtimes::tokio::future_into_py(py, async move { + let v = ctx + .create_topic_reply(topic_id, CreateReplyOptions { body, reply_to_id }) + .await + .map_err(ErrorNewType)?; + TopicReply::try_from(v) + }) + .map(|b| b.unbind()) + } } diff --git a/python/src/content/mod.rs b/python/src/content/mod.rs index 7c8b0c8f9..59aec4049 100644 --- a/python/src/content/mod.rs +++ b/python/src/content/mod.rs @@ -10,6 +10,7 @@ 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::()?; Ok(()) diff --git a/python/src/content/types.rs b/python/src/content/types.rs index bf185ceee..a91853744 100644 --- a/python/src/content/types.rs +++ b/python/src/content/types.rs @@ -94,6 +94,32 @@ pub(crate) struct TopicItem { shares_count: i32, } +/// A reply on a topic +#[pyclass(skip_from_py_object)] +#[derive(Debug, PyObject, Clone)] +#[py(remote = "longbridge::content::TopicReply")] +pub(crate) struct TopicReply { + /// Reply ID + id: String, + /// Topic ID this reply belongs to + topic_id: String, + /// Reply body (plain text) + body: String, + /// ID of the parent reply ("0" means top-level) + reply_to_id: String, + /// Author info + author: TopicAuthor, + /// Attached images + #[py(array)] + images: Vec, + /// Likes count + likes_count: i32, + /// Nested replies count + comments_count: i32, + /// Created time + created_at: PyOffsetDateTimeWrapper, +} + /// News 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 fedf63570..0baf9ccd0 100644 --- a/rust/src/blocking/content.rs +++ b/rust/src/blocking/content.rs @@ -6,7 +6,8 @@ use crate::{ Config, Result, blocking::runtime::BlockingRuntime, content::{ - ContentContext, CreateTopicOptions, MyTopicsOptions, NewsItem, OwnedTopic, TopicItem, + ContentContext, CreateReplyOptions, CreateTopicOptions, ListTopicRepliesOptions, + MyTopicsOptions, NewsItem, OwnedTopic, TopicItem, TopicReply, }, }; @@ -55,4 +56,33 @@ impl ContentContextSync { self.rt .call(move |ctx| async move { ctx.news(symbol).await }) } + + /// Get full details of a topic by its ID + pub fn topic_detail(&self, id: impl Into) -> Result { + let id = id.into(); + self.rt + .call(move |ctx| async move { ctx.topic_detail(id).await }) + } + + /// List replies on a topic + pub fn list_topic_replies( + &self, + topic_id: impl Into, + opts: ListTopicRepliesOptions, + ) -> Result> { + let topic_id = topic_id.into(); + self.rt + .call(move |ctx| async move { ctx.list_topic_replies(topic_id, opts).await }) + } + + /// Post a reply to a topic + pub fn create_topic_reply( + &self, + topic_id: impl Into, + opts: CreateReplyOptions, + ) -> Result { + let topic_id = topic_id.into(); + self.rt + .call(move |ctx| async move { ctx.create_topic_reply(topic_id, opts).await }) + } } diff --git a/rust/src/content/context.rs b/rust/src/content/context.rs index abc71c9d3..092eea9f2 100644 --- a/rust/src/content/context.rs +++ b/rust/src/content/context.rs @@ -3,7 +3,10 @@ use std::sync::Arc; use longbridge_httpcli::{HttpClient, Json, Method}; use serde::Deserialize; -use super::types::{CreateTopicOptions, MyTopicsOptions, NewsItem, OwnedTopic, TopicItem}; +use super::types::{ + CreateReplyOptions, CreateTopicOptions, ListTopicRepliesOptions, MyTopicsOptions, NewsItem, + OwnedTopic, TopicItem, TopicReply, +}; use crate::{Config, Result}; struct InnerContentContext { @@ -22,9 +25,9 @@ impl ContentContext { })) } - /// Get topics created by the current authenticated user + /// Get topics created by the current authenticated user. /// - /// Path: GET /v1/content/topics/mine + /// See: pub async fn my_topics(&self, opts: MyTopicsOptions) -> Result> { #[derive(Debug, Deserialize)] struct Response { @@ -43,9 +46,9 @@ impl ContentContext { .items) } - /// Create a new topic + /// Create a new community topic. /// - /// Path: POST /v1/content/topics + /// See: pub async fn create_topic(&self, opts: CreateTopicOptions) -> Result { #[derive(Debug, Deserialize)] struct TopicId { @@ -89,6 +92,85 @@ impl ContentContext { .items) } + /// Get full details of a topic by its ID. + /// + /// See: + pub async fn topic_detail(&self, id: impl Into) -> Result { + #[derive(Debug, Deserialize)] + struct Response { + item: OwnedTopic, + } + + let id = id.into(); + Ok(self + .0 + .http_cli + .request(Method::GET, format!("/v1/content/topics/{id}")) + .response::>() + .send() + .await? + .0 + .item) + } + + /// List replies on a topic. + /// + /// See: + pub async fn list_topic_replies( + &self, + topic_id: impl Into, + opts: ListTopicRepliesOptions, + ) -> Result> { + #[derive(Debug, Deserialize)] + struct Response { + items: Vec, + } + + let topic_id = topic_id.into(); + Ok(self + .0 + .http_cli + .request( + Method::GET, + format!("/v1/content/topics/{topic_id}/comments"), + ) + .query_params(opts) + .response::>() + .send() + .await? + .0 + .items) + } + + /// Post a reply to a community topic. + /// + /// See: + pub async fn create_topic_reply( + &self, + topic_id: impl Into, + opts: CreateReplyOptions, + ) -> Result { + #[derive(Debug, Deserialize)] + struct Response { + item: TopicReply, + } + + let topic_id = topic_id.into(); + Ok(self + .0 + .http_cli + .request( + Method::POST, + format!("/v1/content/topics/{topic_id}/comments"), + ) + .body(Json(opts)) + .response::>() + .send() + .await? + .0 + .item) + } + /// Get news list pub async fn news(&self, symbol: impl Into) -> Result> { #[derive(Debug, Deserialize)] diff --git a/rust/src/content/mod.rs b/rust/src/content/mod.rs index bad07fbdd..aa038349d 100644 --- a/rust/src/content/mod.rs +++ b/rust/src/content/mod.rs @@ -5,5 +5,6 @@ mod types; pub use context::ContentContext; pub use types::{ - CreateTopicOptions, MyTopicsOptions, NewsItem, OwnedTopic, TopicAuthor, TopicImage, TopicItem, + CreateReplyOptions, CreateTopicOptions, ListTopicRepliesOptions, MyTopicsOptions, NewsItem, + OwnedTopic, TopicAuthor, TopicImage, TopicItem, TopicReply, }; diff --git a/rust/src/content/types.rs b/rust/src/content/types.rs index 0e8e5187a..3d3a04631 100644 --- a/rust/src/content/types.rs +++ b/rust/src/content/types.rs @@ -147,6 +147,64 @@ pub struct TopicItem { pub shares_count: i32, } +/// Options for listing replies on a topic +#[derive(Debug, Default, Clone, Serialize)] +pub struct ListTopicRepliesOptions { + /// Page number (default 1) + #[serde(skip_serializing_if = "Option::is_none")] + pub page: Option, + /// Records per page, range 1~50 (default 20) + #[serde(skip_serializing_if = "Option::is_none")] + pub size: Option, +} + +/// Options for posting a reply to a topic +#[derive(Debug, Clone, Serialize)] +pub struct CreateReplyOptions { + /// Reply body. Plain text only — Markdown is not rendered. + /// + /// Stock symbols mentioned in the body (e.g. `700.HK`, `TSLA.US`) are + /// automatically recognized and linked as related stocks by the platform. + /// Use `tickers` in the parent topic to associate additional stocks not + /// mentioned in the body. + pub body: String, + /// ID of the reply to. Set to `None` to post a top-level reply. + #[serde(skip_serializing_if = "Option::is_none")] + pub reply_to_id: Option, +} + +/// A reply on a topic +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TopicReply { + /// Reply ID + pub id: String, + /// Topic ID this reply belongs to + pub topic_id: String, + /// Reply body (plain text) + #[serde(default)] + pub body: String, + /// ID of the parent reply (`"0"` means top-level) + #[serde(default)] + pub reply_to_id: String, + /// Author info + pub author: TopicAuthor, + /// Attached images + #[serde(default)] + pub images: Vec, + /// Likes count + #[serde(default)] + pub likes_count: i32, + /// Nested replies count + #[serde(default)] + pub comments_count: i32, + /// Created time + #[serde( + serialize_with = "time::serde::rfc3339::serialize", + deserialize_with = "serde_utils::timestamp::deserialize" + )] + pub created_at: OffsetDateTime, +} + /// News item #[derive(Debug, Clone, Serialize, Deserialize)] pub struct NewsItem {