From 208b20642930f9d5fef617d592c3f8627f1d8c54 Mon Sep 17 00:00:00 2001 From: hitalin Date: Sun, 19 Jul 2026 19:33:13 +0900 Subject: [PATCH 1/2] =?UTF-8?q?refactor(streaming):=20noteUpdated=20payloa?= =?UTF-8?q?d=20=E3=82=92=20typed=20NoteUpdateBody=20=E3=81=AB=E7=BD=AE?= =?UTF-8?q?=E3=81=8D=E6=8F=9B=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit notedeck#781 Phase 1。StreamNoteUpdatedEvent / StreamNoteCaptureEvent の update_type: String + body: Value を、adjacent tagging (updateType/body) の NoteUpdateBody enum に置き換える。serde(flatten) により歴史的ワイヤ形 { noteId, updateType, body } は不変で、specta feature 有効時は TS の discriminated union として bindings に載る。 - reacted / unreacted / pollVoted / deleted の 4 variant + 各 body struct - emoji は { name, url } / bare string / null のフォーク揺れを untagged ReactionEmoji で吸収 - 未知の updateType (フォーク拡張) は WS 境界で debug log とともに drop (Inspector の raw tap には影響しない) Co-Authored-By: Claude Fable 5 --- src/models.rs | 141 +++++++++++++++++++++++++++++++++++++++++++++++ src/streaming.rs | 64 ++++++++++++++------- 2 files changed, 186 insertions(+), 19 deletions(-) diff --git a/src/models.rs b/src/models.rs index ccd857b..60007ea 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1076,6 +1076,84 @@ impl std::fmt::Debug for AuthResult { } } +// --- Streaming noteUpdated payloads (#781 typed events) --- + +/// Typed body of a Misskey `noteUpdated` streaming event. +/// Adjacent tagging (`updateType` + `body`) preserves the historical wire shape +/// `{ updateType: "reacted", body: { ... } }` so the JSON consumers read is +/// unchanged while the contract becomes typed end-to-end. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(tag = "updateType", content = "body", rename_all = "camelCase")] +pub enum NoteUpdateBody { + Reacted(NoteReactedBody), + Unreacted(NoteUnreactedBody), + PollVoted(NotePollVotedBody), + Deleted(NoteDeletedBody), +} + +impl NoteUpdateBody { + /// Parse the raw `{ type, body }` of a Misskey `noteUpdated` event. + /// Unknown update types (fork extensions) yield `None` and are dropped at + /// the WS boundary; the inspector's raw tap is unaffected. + pub fn from_raw(update_type: &str, body: Value) -> Option { + Some(match update_type { + "reacted" => Self::Reacted(serde_json::from_value(body).ok()?), + "unreacted" => Self::Unreacted(serde_json::from_value(body).ok()?), + "pollVoted" => Self::PollVoted(serde_json::from_value(body).ok()?), + "deleted" => Self::Deleted(serde_json::from_value(body).ok()?), + _ => return None, + }) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct NoteReactedBody { + pub reaction: String, + /// Custom emoji info。本家は `{ name, url }`、unicode 絵文字は null/欠落。 + /// フォークが bare string を送る揺れもここで吸収する。 + #[serde(default)] + pub emoji: Option, + #[serde(default)] + pub user_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(untagged)] +pub enum ReactionEmoji { + Custom { name: String, url: String }, + Code(String), +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct NoteUnreactedBody { + pub reaction: String, + #[serde(default)] + pub user_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct NotePollVotedBody { + pub choice: i64, + #[serde(default)] + pub user_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[cfg_attr(feature = "specta", derive(specta::Type))] +#[serde(rename_all = "camelCase")] +pub struct NoteDeletedBody { + #[serde(default)] + pub deleted_at: Option, +} + // --- Raw Misskey API response types (for deserialization) --- #[derive(Debug, Deserialize)] @@ -1602,6 +1680,69 @@ mod tests { use super::*; use serde_json::json; + // ---- NoteUpdateBody (#781) ---- + + #[test] + fn note_update_body_from_raw_reacted_parses_custom_emoji() { + let body = json!({ + "reaction": ":ablobcat:", + "emoji": { "name": "ablobcat", "url": "https://example.com/e.png" }, + "userId": "u1" + }); + let parsed = NoteUpdateBody::from_raw("reacted", body).expect("should parse"); + match &parsed { + NoteUpdateBody::Reacted(b) => { + assert_eq!(b.reaction, ":ablobcat:"); + assert!(matches!(b.emoji, Some(ReactionEmoji::Custom { .. }))); + assert_eq!(b.user_id.as_deref(), Some("u1")); + } + other => panic!("expected Reacted, got {other:?}"), + } + // ワイヤ形は歴史的な { updateType, body } を維持する + let wire = serde_json::to_value(&parsed).unwrap(); + assert_eq!(wire["updateType"], "reacted"); + assert_eq!(wire["body"]["reaction"], ":ablobcat:"); + } + + #[test] + fn note_update_body_from_raw_reacted_accepts_null_and_string_emoji() { + // unicode 絵文字: emoji は null + let parsed = + NoteUpdateBody::from_raw("reacted", json!({ "reaction": "👍", "emoji": null })) + .expect("null emoji should parse"); + assert!(matches!(parsed, NoteUpdateBody::Reacted(ref b) if b.emoji.is_none())); + + // フォーク揺れ: emoji が bare string + let parsed = + NoteUpdateBody::from_raw("reacted", json!({ "reaction": "x", "emoji": "blob" })) + .expect("string emoji should parse"); + assert!(matches!( + parsed, + NoteUpdateBody::Reacted(ref b) + if matches!(b.emoji, Some(ReactionEmoji::Code(_))) + )); + } + + #[test] + fn note_update_body_from_raw_deleted_and_poll_voted() { + let deleted = + NoteUpdateBody::from_raw("deleted", json!({ "deletedAt": "2026-01-01T00:00:00Z" })) + .expect("deleted should parse"); + assert!(matches!( + deleted, + NoteUpdateBody::Deleted(ref b) if b.deleted_at.is_some() + )); + + let voted = NoteUpdateBody::from_raw("pollVoted", json!({ "choice": 2, "userId": "u1" })) + .expect("pollVoted should parse"); + assert!(matches!(voted, NoteUpdateBody::PollVoted(ref b) if b.choice == 2)); + } + + #[test] + fn note_update_body_from_raw_unknown_type_is_none() { + assert!(NoteUpdateBody::from_raw("madePrivate", json!({})).is_none()); + } + // ---- TimelineType ---- #[test] diff --git a/src/streaming.rs b/src/streaming.rs index b0ef924..dbab214 100644 --- a/src/streaming.rs +++ b/src/streaming.rs @@ -14,8 +14,8 @@ use crate::db::Database; use crate::error::NoteDeckError; use crate::event_bus::{EventBus, SseEvent}; use crate::models::{ - ChatMessage, ChatReactionUser, NormalizedNote, NormalizedNotification, RawNote, - RawNotification, TimelineOptions, TimelineType, + ChatMessage, ChatReactionUser, NormalizedNote, NormalizedNotification, NoteReactedBody, + NoteUnreactedBody, NoteUpdateBody, RawNote, RawNotification, TimelineOptions, TimelineType, }; /// Trait for emitting events to a frontend (e.g., Tauri WebView). @@ -177,8 +177,9 @@ pub struct StreamNoteUpdatedEvent { pub account_id: String, pub subscription_id: String, pub note_id: String, - pub update_type: String, - pub body: Value, + /// flatten でワイヤ形 `{ updateType, body }` を維持する + #[serde(flatten)] + pub update: NoteUpdateBody, } #[derive(Debug, Clone, Serialize)] @@ -186,8 +187,8 @@ pub struct StreamNoteUpdatedEvent { pub struct StreamNoteCaptureEvent { pub account_id: String, pub note_id: String, - pub update_type: String, - pub body: Value, + #[serde(flatten)] + pub update: NoteUpdateBody, } #[derive(Debug, Clone, Serialize)] @@ -1327,11 +1328,14 @@ async fn handle_ws_message( .unwrap_or_default() .to_owned(); let update_body = body.get_mut("body").map(Value::take).unwrap_or_default(); + let Some(update) = NoteUpdateBody::from_raw(&update_type, update_body) else { + tracing::debug!(update_type, "unknown noteUpdated type; dropped"); + return; + }; let payload = StreamNoteCaptureEvent { account_id: account_id.to_string(), note_id, - update_type, - body: update_body, + update, }; emit_event!( emitter, @@ -1412,12 +1416,15 @@ async fn handle_ws_message( .get_mut("body") .map(Value::take) .unwrap_or_default(); + let Some(update) = NoteUpdateBody::from_raw(&update_type, update_body) else { + tracing::debug!(update_type, "unknown noteUpdated type; dropped"); + return; + }; let payload = StreamNoteUpdatedEvent { account_id: account_id.to_string(), subscription_id: sub_id, note_id, - update_type, - body: update_body, + update, }; emit_event!( emitter, @@ -1757,11 +1764,10 @@ async fn polling_loop( let payload = StreamNoteCaptureEvent { account_id: account_id.clone(), note_id: note_id.clone(), - update_type: "reacted".to_string(), - body: json!({ - "reaction": reaction, - "emoji": null, - "userId": null, + update: NoteUpdateBody::Reacted(NoteReactedBody { + reaction: reaction.clone(), + emoji: None, + user_id: None, }), }; emit_or_log!(emitter, "stream-note-capture-updated", payload); @@ -1774,10 +1780,9 @@ async fn polling_loop( let payload = StreamNoteCaptureEvent { account_id: account_id.clone(), note_id: note_id.clone(), - update_type: "unreacted".to_string(), - body: json!({ - "reaction": reaction, - "userId": null, + update: NoteUpdateBody::Unreacted(NoteUnreactedBody { + reaction: reaction.clone(), + user_id: None, }), }; emit_or_log!(emitter, "stream-note-capture-updated", payload); @@ -1844,6 +1849,27 @@ mod tests { } } + #[test] + fn stream_note_updated_event_keeps_wire_shape() { + // #781: NoteUpdateBody を flatten しても歴史的ワイヤ形 + // { noteId, updateType, body } が保たれることの契約テスト。 + let payload = StreamNoteUpdatedEvent { + account_id: "acc-1".into(), + subscription_id: "sub-1".into(), + note_id: "n1".into(), + update: NoteUpdateBody::from_raw( + "reacted", + serde_json::json!({ "reaction": ":+1:", "userId": "u1" }), + ) + .unwrap(), + }; + let wire = serde_json::to_value(&payload).unwrap(); + assert_eq!(wire["noteId"], "n1"); + assert_eq!(wire["updateType"], "reacted"); + assert_eq!(wire["body"]["reaction"], ":+1:"); + assert_eq!(wire["accountId"], "acc-1"); + } + #[test] fn read_idle_timeout_exceeds_ping_interval() { // The watchdog must outlast the ping cadence, otherwise a live but quiet From 684f6a2d89dc9fb9cc6366560c2b69ad45ecc47a Mon Sep 17 00:00:00 2001 From: hitalin Date: Sun, 19 Jul 2026 20:03:26 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(streaming):=20deleted=20=E3=81=AE=20nul?= =?UTF-8?q?l=20body=20=E3=82=92=E8=A8=B1=E5=AE=B9=E3=81=97=E3=81=A6?= =?UTF-8?q?=E5=89=8A=E9=99=A4=E3=82=A4=E3=83=99=E3=83=B3=E3=83=88=E3=81=AE?= =?UTF-8?q?=20drop=20=E3=82=92=E9=98=B2=E3=81=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit body を省略するフォークで noteUpdated deleted が握り潰されると ゴーストノートが残るため、null は空 body として扱う。 Co-Authored-By: Claude Fable 5 --- src/models.rs | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/models.rs b/src/models.rs index 60007ea..b216820 100644 --- a/src/models.rs +++ b/src/models.rs @@ -1101,6 +1101,9 @@ impl NoteUpdateBody { "reacted" => Self::Reacted(serde_json::from_value(body).ok()?), "unreacted" => Self::Unreacted(serde_json::from_value(body).ok()?), "pollVoted" => Self::PollVoted(serde_json::from_value(body).ok()?), + // deleted は body を省略するフォークがありうる。削除イベントを + // 落とすとゴーストノートが残るため null は空 body として扱う。 + "deleted" if body.is_null() => Self::Deleted(NoteDeletedBody { deleted_at: None }), "deleted" => Self::Deleted(serde_json::from_value(body).ok()?), _ => return None, }) @@ -1743,6 +1746,17 @@ mod tests { assert!(NoteUpdateBody::from_raw("madePrivate", json!({})).is_none()); } + #[test] + fn note_update_body_from_raw_deleted_tolerates_null_body() { + // body を省略するフォークで削除イベントを落とさない + let parsed = NoteUpdateBody::from_raw("deleted", Value::Null) + .expect("null body deleted should parse"); + assert!(matches!( + parsed, + NoteUpdateBody::Deleted(ref b) if b.deleted_at.is_none() + )); + } + // ---- TimelineType ---- #[test]