Skip to main content

agent_protocols/
discourse.rs

1use std::collections::{BTreeMap, BTreeSet};
2
3use base64::engine::general_purpose::URL_SAFE_NO_PAD;
4use base64::Engine as _;
5use serde::{Deserialize, Serialize};
6use serde_json::Value;
7use sha3::{Digest, Sha3_256};
8
9use crate::error::{Result, SdkError};
10use crate::identity::{verify_envelope, AgentId, Envelope, Event};
11
12pub const PROTOCOL: &str = "agent-discourse/1.0";
13
14pub mod event_type {
15    pub const ROOM_CREATE: &str = "room.create";
16    pub const ROOM_JOIN: &str = "room.join";
17    pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
18    pub const ROOM_LEAVE: &str = "room.leave";
19    pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
20    pub const ROOM_CLOSE: &str = "room.close";
21    pub const ROOM_CANCEL: &str = "room.cancel";
22    pub const MESSAGE_CREATE: &str = "message.create";
23    pub const REACTION_CREATE: &str = "reaction.create";
24    pub const MESSAGE_PROPOSAL_CREATE: &str = "message.proposal.create";
25    pub const MESSAGE_POLL_CREATE: &str = "message.poll.create";
26    pub const MESSAGE_POLL_VOTE: &str = "message.poll.vote";
27    pub const MESSAGE_RESOLUTION_CREATE: &str = "message.resolution.create";
28    pub const SOURCE_ADD: &str = "source.add";
29    pub const TURN_UPDATE: &str = "turn.update";
30    pub const QUESTION_CREATE: &str = "question.create";
31    pub const ROOM_STEER: &str = "room.steer";
32    pub const MAP_UPDATE: &str = "map.update";
33    pub const ARTIFACT_CREATE: &str = "artifact.create";
34    pub const SESSION_OFFER: &str = "session.offer";
35    pub const SESSION_ANSWER: &str = "session.answer";
36    pub const SESSION_CANDIDATE: &str = "session.candidate";
37    pub const SESSION_CLOSE: &str = "session.close";
38}
39
40#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
41#[serde(rename_all = "snake_case")]
42pub enum RoomState {
43    Scheduled,
44    Active,
45    Ended,
46    Cancelled,
47}
48
49#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
50#[serde(rename_all = "snake_case")]
51pub enum Visibility {
52    Public,
53    Restricted,
54    Private,
55}
56
57#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
58#[serde(rename_all = "snake_case")]
59pub enum TurnPolicy {
60    Free,
61    RoundRobin,
62    ModeratorLed,
63}
64
65#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "snake_case")]
67pub enum Role {
68    Moderator,
69    Expert,
70    Participant,
71    Observer,
72}
73
74#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "snake_case")]
76pub enum JoinRequestStatus {
77    Pending,
78    Approved,
79    Rejected,
80    Expired,
81}
82
83#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "snake_case")]
85pub enum MessageIntent {
86    Question,
87    Answer,
88    Clarification,
89    Critique,
90    Synthesis,
91    FollowUp,
92    Other,
93}
94
95#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
96#[serde(rename_all = "snake_case")]
97pub enum MapOperation {
98    UpsertNode,
99    MoveNode,
100    DeleteNode,
101    MergeNodes,
102    ReplaceSnapshot,
103    MarkResolved,
104}
105
106#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
107#[serde(rename_all = "snake_case")]
108pub enum ResolutionOutcome {
109    Accepted,
110    Rejected,
111    Deferred,
112    Superseded,
113}
114
115#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "snake_case")]
117pub enum SessionType {
118    Webrtc,
119}
120
121#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
122#[serde(rename_all = "snake_case")]
123pub enum SessionMediaKind {
124    Audio,
125    Video,
126    Screen,
127    Data,
128    File,
129}
130
131#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
132#[serde(rename_all = "snake_case")]
133pub enum SessionTopology {
134    PeerToPeer,
135    Sfu,
136    TurnRelay,
137}
138
139#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
140#[serde(rename_all = "snake_case")]
141pub enum SessionDescriptionType {
142    Offer,
143    Answer,
144}
145
146#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
147pub struct RoomCreatePayload {
148    pub topic: String,
149    #[serde(default, skip_serializing_if = "Option::is_none")]
150    pub agenda: Option<String>,
151    pub visibility: Visibility,
152    pub start_time: i64,
153    pub end_time: i64,
154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
155    pub tags: Vec<String>,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub language: Option<String>,
158    #[serde(default, skip_serializing_if = "Option::is_none")]
159    pub policy: Option<RoomPolicy>,
160    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
161    pub extensions: BTreeMap<String, Value>,
162    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
163    pub extra: BTreeMap<String, Value>,
164}
165
166#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
167pub struct RoomPolicy {
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub turn_policy: Option<TurnPolicy>,
170    #[serde(default, skip_serializing_if = "Vec::is_empty")]
171    pub moderator_agent_ids: Vec<AgentId>,
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub max_participants: Option<u32>,
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub observer_allowed: Option<bool>,
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub observer_steering_allowed: Option<bool>,
178    #[serde(default, flatten)]
179    pub extra: BTreeMap<String, Value>,
180}
181
182impl RoomPolicy {
183    pub fn new() -> Self {
184        Self::default()
185    }
186}
187
188impl RoomCreatePayload {
189    pub fn new(
190        topic: impl Into<String>,
191        visibility: Visibility,
192        start_time: i64,
193        end_time: i64,
194    ) -> Self {
195        Self {
196            topic: topic.into(),
197            agenda: None,
198            visibility,
199            start_time,
200            end_time,
201            tags: Vec::new(),
202            language: None,
203            policy: None,
204            extensions: BTreeMap::new(),
205            extra: BTreeMap::new(),
206        }
207    }
208}
209
210#[derive(Clone, Debug, Serialize, Deserialize)]
211pub struct ServerRecord<P = Value> {
212    pub room_id: String,
213    pub seq: u64,
214    #[serde(default)]
215    pub pre_hash: Option<String>,
216    pub hash: String,
217    pub received_at: i64,
218    pub envelope: Envelope<P>,
219}
220
221#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
222pub struct ServerRecordHashPayload {
223    pub room_id: String,
224    pub seq: u64,
225    pub pre_hash: Option<String>,
226    pub envelope_hash: String,
227    pub received_at: i64,
228}
229
230#[derive(Clone, Debug, Serialize, Deserialize)]
231pub struct RoomResponse {
232    pub id: String,
233    pub status: RoomState,
234    pub url: String,
235    pub seq: u64,
236    #[serde(default)]
237    pub pre_hash: Option<String>,
238    pub hash: String,
239    pub received_at: i64,
240    pub envelope: Option<Envelope<RoomCreatePayload>>,
241}
242
243#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
244pub struct RoomJoinPayload {
245    pub request_id: String,
246    pub role: Role,
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub perspective: Option<String>,
249}
250
251#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
252pub struct RoomJoinRequestPayload {
253    pub requested_role: Role,
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub perspective: Option<String>,
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    pub reason: Option<String>,
258    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
259    pub extra: BTreeMap<String, Value>,
260}
261
262impl RoomJoinRequestPayload {
263    pub fn new(requested_role: Role) -> Self {
264        Self {
265            requested_role,
266            perspective: None,
267            reason: None,
268            extra: BTreeMap::new(),
269        }
270    }
271}
272
273#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
274pub struct RoomJoinRequest {
275    pub id: String,
276    pub room_id: String,
277    pub applicant: AgentId,
278    pub requested_role: Role,
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub approved_role: Option<Role>,
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub perspective: Option<String>,
283    pub status: JoinRequestStatus,
284    #[serde(default, skip_serializing_if = "Option::is_none")]
285    pub request_reason: Option<String>,
286    #[serde(default, skip_serializing_if = "Option::is_none")]
287    pub review_reason: Option<String>,
288    pub created_at: i64,
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub reviewed_by: Option<AgentId>,
291    #[serde(default, skip_serializing_if = "Option::is_none")]
292    pub reviewed_at: Option<i64>,
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub expires_at: Option<i64>,
295    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
296    pub extra: BTreeMap<String, Value>,
297}
298
299#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
300#[serde(rename_all = "snake_case")]
301pub enum JoinDecision {
302    Approve,
303    Reject,
304}
305
306#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
307pub struct RoomJoinReviewPayload {
308    pub request_id: String,
309    pub member: AgentId,
310    pub decision: JoinDecision,
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub role: Option<Role>,
313    #[serde(default, skip_serializing_if = "Option::is_none")]
314    pub reason: Option<String>,
315}
316
317#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
318pub struct RoomLeavePayload {
319    #[serde(default, skip_serializing_if = "Option::is_none")]
320    pub reason: Option<String>,
321}
322
323#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
324pub struct RoleUpdatePayload {
325    pub member: AgentId,
326    pub role: Role,
327    #[serde(default, skip_serializing_if = "Option::is_none")]
328    pub reason: Option<String>,
329}
330
331#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
332pub struct MessageCreatePayload {
333    pub content_type: String,
334    pub content: Value,
335    #[serde(default, skip_serializing_if = "Vec::is_empty")]
336    pub references: Vec<String>,
337}
338
339impl MessageCreatePayload {
340    pub fn new(content_type: impl Into<String>, content: Value) -> Self {
341        Self {
342            content_type: content_type.into(),
343            content,
344            references: Vec::new(),
345        }
346    }
347
348    pub fn text(text: impl Into<String>) -> Self {
349        Self::new("text/plain", Value::String(text.into()))
350    }
351
352    pub fn markdown(markdown: impl Into<String>) -> Self {
353        Self::new("text/markdown", Value::String(markdown.into()))
354    }
355}
356
357#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
358pub struct ProposalCreatePayload {
359    pub proposal_id: String,
360    pub title: String,
361    pub body: String,
362    #[serde(default, skip_serializing_if = "Option::is_none")]
363    pub content_type: Option<String>,
364    #[serde(default, skip_serializing_if = "Vec::is_empty")]
365    pub references: Vec<String>,
366    #[serde(default, skip_serializing_if = "Vec::is_empty")]
367    pub source_event_ids: Vec<String>,
368    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
369    pub extra: BTreeMap<String, Value>,
370}
371
372#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
373pub struct PollOption {
374    pub id: String,
375    pub label: String,
376    #[serde(default, skip_serializing_if = "Option::is_none")]
377    pub description: Option<String>,
378}
379
380#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
381pub struct PollCreatePayload {
382    pub poll_id: String,
383    pub question: String,
384    pub options: Vec<PollOption>,
385    #[serde(default, skip_serializing_if = "Option::is_none")]
386    pub min_choices: Option<u32>,
387    #[serde(default, skip_serializing_if = "Option::is_none")]
388    pub max_choices: Option<u32>,
389    #[serde(default, skip_serializing_if = "Option::is_none")]
390    pub closes_at: Option<i64>,
391    #[serde(default, skip_serializing_if = "Vec::is_empty")]
392    pub references: Vec<String>,
393    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
394    pub extra: BTreeMap<String, Value>,
395}
396
397#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
398pub struct PollVotePayload {
399    pub event_id: String,
400    pub option_ids: Vec<String>,
401}
402
403#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
404pub struct ResolutionCreatePayload {
405    pub resolution_id: String,
406    pub outcome: ResolutionOutcome,
407    pub summary: String,
408    #[serde(default, skip_serializing_if = "Option::is_none")]
409    pub proposal_event_id: Option<String>,
410    #[serde(default, skip_serializing_if = "Option::is_none")]
411    pub poll_event_id: Option<String>,
412    #[serde(default, skip_serializing_if = "Vec::is_empty")]
413    pub references: Vec<String>,
414    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
415    pub extra: BTreeMap<String, Value>,
416}
417
418#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
419pub struct ReactionCreatePayload {
420    pub event_id: String,
421    pub reaction: String,
422    #[serde(default, skip_serializing_if = "Option::is_none")]
423    pub score: Option<f64>,
424}
425
426impl ReactionCreatePayload {
427    pub fn new(event_id: impl Into<String>, reaction: impl Into<String>) -> Self {
428        Self {
429            event_id: event_id.into(),
430            reaction: reaction.into(),
431            score: None,
432        }
433    }
434}
435
436#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
437pub struct SourceAddPayload {
438    pub source_type: String,
439    pub uri: String,
440    #[serde(default, skip_serializing_if = "Option::is_none")]
441    pub title: Option<String>,
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub retrieved_at: Option<i64>,
444    #[serde(default, skip_serializing_if = "Option::is_none")]
445    pub content_digest: Option<String>,
446    #[serde(default, skip_serializing_if = "Option::is_none")]
447    pub excerpt: Option<String>,
448    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
449    pub extra: BTreeMap<String, Value>,
450}
451
452#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
453pub struct TurnUpdatePayload {
454    pub turn_id: u64,
455    pub speaker: AgentId,
456    #[serde(default, skip_serializing_if = "Option::is_none")]
457    pub intent: Option<MessageIntent>,
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub topic: Option<String>,
460    #[serde(default, skip_serializing_if = "Option::is_none")]
461    pub expires_at: Option<i64>,
462    #[serde(default, skip_serializing_if = "Option::is_none")]
463    pub reason: Option<String>,
464}
465
466#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
467pub struct QuestionGeneratePayload {
468    pub question: String,
469    #[serde(default, skip_serializing_if = "Vec::is_empty")]
470    pub target_perspectives: Vec<String>,
471    #[serde(default, skip_serializing_if = "Option::is_none")]
472    pub basis: Option<String>,
473    #[serde(default, skip_serializing_if = "Vec::is_empty")]
474    pub source_event_ids: Vec<String>,
475    #[serde(default, skip_serializing_if = "Vec::is_empty")]
476    pub references: Vec<String>,
477    #[serde(default, skip_serializing_if = "Option::is_none")]
478    pub priority: Option<f64>,
479}
480
481#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
482pub struct DiscourseSteerPayload {
483    pub instruction: String,
484    pub target: String,
485    #[serde(default, skip_serializing_if = "Option::is_none")]
486    pub priority: Option<f64>,
487    #[serde(default, skip_serializing_if = "Vec::is_empty")]
488    pub references: Vec<String>,
489}
490
491#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
492#[serde(rename_all = "snake_case")]
493pub enum MapNodeStatus {
494    Open,
495    Resolved,
496    Closed,
497}
498
499#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
500pub struct MapNode {
501    pub id: String,
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub parent_id: Option<String>,
504    pub title: String,
505    #[serde(default, skip_serializing_if = "Option::is_none")]
506    pub summary: Option<String>,
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub status: Option<MapNodeStatus>,
509    #[serde(default, skip_serializing_if = "Vec::is_empty")]
510    pub source_event_ids: Vec<String>,
511    #[serde(default, skip_serializing_if = "Vec::is_empty")]
512    pub discussion_event_ids: Vec<String>,
513    #[serde(default, skip_serializing_if = "Vec::is_empty")]
514    pub children: Vec<MapNode>,
515    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
516    pub extra: BTreeMap<String, Value>,
517}
518
519#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
520pub struct MapUpdatePayload {
521    pub operation: MapOperation,
522    #[serde(default, skip_serializing_if = "Option::is_none")]
523    pub node: Option<MapNode>,
524    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
525    pub extra: BTreeMap<String, Value>,
526    #[serde(default, flatten)]
527    pub extensions: BTreeMap<String, Value>,
528}
529
530#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
531pub struct ArtifactCreatePayload {
532    pub artifact_id: String,
533    pub format: String,
534    pub title: String,
535    pub uri: String,
536    #[serde(default, skip_serializing_if = "Option::is_none")]
537    pub content_digest: Option<String>,
538    #[serde(default, skip_serializing_if = "Vec::is_empty")]
539    pub source_event_ids: Vec<String>,
540    #[serde(default, skip_serializing_if = "Vec::is_empty")]
541    pub discussion_event_ids: Vec<String>,
542    #[serde(default, skip_serializing_if = "Option::is_none")]
543    pub map_event_id: Option<String>,
544}
545
546#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
547pub struct SessionDescription {
548    #[serde(rename = "type")]
549    pub kind: SessionDescriptionType,
550    pub sdp: String,
551}
552
553#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
554pub struct SessionIceCandidate {
555    pub candidate: String,
556    #[serde(default, skip_serializing_if = "Option::is_none")]
557    pub sdp_mid: Option<String>,
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub sdp_mline_index: Option<u32>,
560    #[serde(default, skip_serializing_if = "Option::is_none")]
561    pub username_fragment: Option<String>,
562}
563
564#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
565pub struct SessionDataTransfer {
566    pub transfer_id: String,
567    #[serde(default, skip_serializing_if = "Option::is_none")]
568    pub file_name: Option<String>,
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub size_bytes: Option<u64>,
571    #[serde(default, skip_serializing_if = "Option::is_none")]
572    pub mime_type: Option<String>,
573    #[serde(default, skip_serializing_if = "Option::is_none")]
574    pub content_digest: Option<String>,
575}
576
577#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
578pub struct SessionOfferPayload {
579    pub session_id: String,
580    pub session_type: SessionType,
581    pub media: Vec<SessionMediaKind>,
582    pub description: SessionDescription,
583    #[serde(default, skip_serializing_if = "Vec::is_empty")]
584    pub to: Vec<AgentId>,
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub topology: Option<SessionTopology>,
587    #[serde(default, skip_serializing_if = "Vec::is_empty")]
588    pub transfers: Vec<SessionDataTransfer>,
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub expires_at: Option<i64>,
591    #[serde(default, skip_serializing_if = "Vec::is_empty")]
592    pub references: Vec<String>,
593    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
594    pub extra: BTreeMap<String, Value>,
595}
596
597#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
598pub struct SessionAnswerPayload {
599    pub session_id: String,
600    pub offer_event_id: String,
601    pub description: SessionDescription,
602    #[serde(default, skip_serializing_if = "Vec::is_empty")]
603    pub accepted_media: Vec<SessionMediaKind>,
604    #[serde(default, skip_serializing_if = "Vec::is_empty")]
605    pub transfers: Vec<SessionDataTransfer>,
606    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
607    pub extra: BTreeMap<String, Value>,
608}
609
610#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
611pub struct SessionCandidatePayload {
612    pub session_id: String,
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub candidate: Option<SessionIceCandidate>,
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub target: Option<AgentId>,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub end_of_candidates: Option<bool>,
619    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
620    pub extra: BTreeMap<String, Value>,
621}
622
623#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
624pub struct SessionClosePayload {
625    pub session_id: String,
626    #[serde(default, skip_serializing_if = "Option::is_none")]
627    pub reason: Option<String>,
628    #[serde(default, skip_serializing_if = "Vec::is_empty")]
629    pub references: Vec<String>,
630}
631
632#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
633pub struct RoomClosePayload {
634    #[serde(default, skip_serializing_if = "Option::is_none")]
635    pub reason: Option<String>,
636}
637
638#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
639pub struct RoomCancelPayload {
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub reason: Option<String>,
642}
643
644#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
645pub struct ProfileResolverMetadata {
646    pub mode: String,
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub service: Option<String>,
649    #[serde(default, skip_serializing_if = "Option::is_none")]
650    pub protocol: Option<String>,
651}
652
653#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
654pub struct DiscourseProtocolDiscovery {
655    pub protocol: String,
656    pub host: String,
657    #[serde(default, skip_serializing_if = "Vec::is_empty")]
658    pub features: Vec<String>,
659    #[serde(default, skip_serializing_if = "Option::is_none")]
660    pub profile: Option<ProfileResolverMetadata>,
661    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
662    pub endpoints: BTreeMap<String, String>,
663}
664
665#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
666pub struct MapSnapshotRef {
667    pub event_id: String,
668    pub digest: String,
669}
670
671#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
672pub struct ArtifactManifest {
673    pub artifact_id: String,
674    #[serde(rename = "type")]
675    pub kind: String,
676    pub format: String,
677    pub uri: String,
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub content_digest: Option<String>,
680}
681
682#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
683pub struct ArchiveManifest {
684    pub protocol: String,
685    #[serde(rename = "type")]
686    pub kind: String,
687    pub host: String,
688    pub room_id: String,
689    pub url: String,
690    pub generated_at: i64,
691    pub event_count: u64,
692    pub first_seq: u64,
693    pub last_seq: u64,
694    pub last_hash: String,
695    pub events_sha3_256: String,
696    pub archive_root: String,
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub map_snapshot: Option<MapSnapshotRef>,
699    #[serde(default, skip_serializing_if = "Vec::is_empty")]
700    pub artifacts: Vec<ArtifactManifest>,
701    #[serde(default, skip_serializing_if = "Option::is_none")]
702    pub discourse_trace_quality_score: Option<f64>,
703    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
704    pub formats: BTreeMap<String, String>,
705}
706
707pub fn room_create_event(
708    actor: AgentId,
709    created_at: i64,
710    nonce: u64,
711    payload: RoomCreatePayload,
712) -> Event<RoomCreatePayload> {
713    Event::new(
714        PROTOCOL,
715        event_type::ROOM_CREATE,
716        actor,
717        created_at,
718        nonce,
719        payload,
720    )
721}
722
723pub fn discourse_event<P>(
724    kind: impl Into<String>,
725    actor: AgentId,
726    created_at: i64,
727    nonce: u64,
728    room_id: impl Into<String>,
729    payload: P,
730) -> Event<P> {
731    Event::new(PROTOCOL, kind, actor, created_at, nonce, payload).with_room_id(room_id)
732}
733
734pub fn validate_discourse_envelope<P>(envelope: &Envelope<P>) -> Result<()>
735where
736    P: Serialize,
737{
738    verify_envelope(envelope)?;
739    let protocol = envelope.event.protocol.as_str();
740    if protocol != PROTOCOL {
741        return Err(SdkError::InvalidEventProtocol {
742            expected: PROTOCOL.to_owned(),
743            actual: envelope.event.protocol.clone(),
744        });
745    }
746    if event_requires_room_id(&envelope.event.kind) && envelope.event.room_id.is_none() {
747        return Err(SdkError::MissingRoomId);
748    }
749    Ok(())
750}
751
752pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
753    match envelope.event.room_id.as_deref() {
754        Some(actual) if actual == path_room_id => Ok(()),
755        Some(actual) => Err(SdkError::RoomIdMismatch {
756            expected: path_room_id.to_owned(),
757            actual: actual.to_owned(),
758        }),
759        None if envelope.event.kind == event_type::ROOM_CREATE => Ok(()),
760        None => Err(SdkError::MissingRoomId),
761    }
762}
763
764pub fn event_requires_room_id(event_type: &str) -> bool {
765    event_type != event_type::ROOM_CREATE
766}
767
768pub fn validate_room_create_payload(payload: &RoomCreatePayload) -> Result<()> {
769    if payload.topic.trim().is_empty() {
770        return Err(SdkError::InvalidPayload(
771            "room topic must not be empty".to_owned(),
772        ));
773    }
774    if payload.start_time >= payload.end_time {
775        return Err(SdkError::InvalidPayload(
776            "start_time must be before end_time".to_owned(),
777        ));
778    }
779    if let Some(policy) = &payload.policy {
780        if matches!(policy.max_participants, Some(0)) {
781            return Err(SdkError::InvalidPayload(
782                "max_participants must be a positive integer".to_owned(),
783            ));
784        }
785    }
786    Ok(())
787}
788
789pub fn validate_poll_create_payload(payload: &PollCreatePayload) -> Result<()> {
790    if payload.poll_id.trim().is_empty() || payload.question.trim().is_empty() {
791        return Err(SdkError::InvalidPayload(
792            "poll_id and question are required".to_owned(),
793        ));
794    }
795    if payload.options.len() < 2 {
796        return Err(SdkError::InvalidPayload(
797            "poll requires at least two options".to_owned(),
798        ));
799    }
800    let mut option_ids = BTreeSet::new();
801    for option in &payload.options {
802        if option.id.trim().is_empty() || option.label.trim().is_empty() {
803            return Err(SdkError::InvalidPayload(
804                "option id and label are required".to_owned(),
805            ));
806        }
807        if !option_ids.insert(option.id.as_str()) {
808            return Err(SdkError::InvalidPayload(
809                "poll option ids must be unique".to_owned(),
810            ));
811        }
812    }
813    let min_choices = payload.min_choices.unwrap_or(1);
814    let max_choices = payload.max_choices.unwrap_or(1);
815    if min_choices < 1 || max_choices < min_choices {
816        return Err(SdkError::InvalidPayload(
817            "invalid poll choice limits".to_owned(),
818        ));
819    }
820    Ok(())
821}
822
823pub fn validate_poll_vote_payload(
824    payload: &PollVotePayload,
825    poll: &PollCreatePayload,
826    now_ms: Option<i64>,
827) -> Result<()> {
828    if let (Some(closes_at), Some(now_ms)) = (poll.closes_at, now_ms) {
829        if now_ms > closes_at {
830            return Err(SdkError::InvalidPayload("poll is closed".to_owned()));
831        }
832    }
833    let min_choices = poll.min_choices.unwrap_or(1) as usize;
834    let max_choices = poll.max_choices.unwrap_or(1) as usize;
835    let option_ids: BTreeSet<&str> = poll
836        .options
837        .iter()
838        .map(|option| option.id.as_str())
839        .collect();
840    let selected: BTreeSet<&str> = payload.option_ids.iter().map(String::as_str).collect();
841    if selected.len() != payload.option_ids.len() {
842        return Err(SdkError::InvalidPayload(
843            "duplicate poll options".to_owned(),
844        ));
845    }
846    if selected.len() < min_choices || selected.len() > max_choices {
847        return Err(SdkError::InvalidPayload(
848            "invalid number of options".to_owned(),
849        ));
850    }
851    if selected
852        .iter()
853        .any(|option_id| !option_ids.contains(option_id))
854    {
855        return Err(SdkError::InvalidPayload("unknown poll option".to_owned()));
856    }
857    Ok(())
858}
859
860pub fn validate_session_offer_payload(payload: &SessionOfferPayload) -> Result<()> {
861    validate_session_id(&payload.session_id)?;
862    if payload.media.is_empty() {
863        return Err(SdkError::InvalidPayload(
864            "media must not be empty".to_owned(),
865        ));
866    }
867    validate_session_description(&payload.description, SessionDescriptionType::Offer)?;
868    validate_session_transfers(&payload.transfers)
869}
870
871pub fn validate_session_answer_payload(payload: &SessionAnswerPayload) -> Result<()> {
872    validate_session_id(&payload.session_id)?;
873    if payload.offer_event_id.trim().is_empty() {
874        return Err(SdkError::InvalidPayload(
875            "offer_event_id is required".to_owned(),
876        ));
877    }
878    validate_session_description(&payload.description, SessionDescriptionType::Answer)?;
879    validate_session_transfers(&payload.transfers)
880}
881
882pub fn validate_session_candidate_payload(payload: &SessionCandidatePayload) -> Result<()> {
883    validate_session_id(&payload.session_id)?;
884    if payload.end_of_candidates.unwrap_or(false) {
885        return Ok(());
886    }
887    match &payload.candidate {
888        Some(candidate) if !candidate.candidate.trim().is_empty() => Ok(()),
889        _ => Err(SdkError::InvalidPayload(
890            "candidate is required unless end_of_candidates is true".to_owned(),
891        )),
892    }
893}
894
895pub fn server_record_hash_payload(
896    room_id: &str,
897    seq: u64,
898    pre_hash: Option<&str>,
899    envelope_hash: &str,
900    received_at: i64,
901) -> ServerRecordHashPayload {
902    ServerRecordHashPayload {
903        room_id: room_id.to_owned(),
904        seq,
905        pre_hash: pre_hash.map(str::to_owned),
906        envelope_hash: envelope_hash.to_owned(),
907        received_at,
908    }
909}
910
911pub fn server_record_hash(
912    room_id: &str,
913    seq: u64,
914    pre_hash: Option<&str>,
915    envelope_hash: &str,
916    received_at: i64,
917) -> Result<String> {
918    hash_canonical_json(&server_record_hash_payload(
919        room_id,
920        seq,
921        pre_hash,
922        envelope_hash,
923        received_at,
924    ))
925}
926
927pub fn build_server_record<P>(
928    room_id: impl Into<String>,
929    seq: u64,
930    pre_hash: Option<String>,
931    received_at: i64,
932    envelope: Envelope<P>,
933) -> Result<ServerRecord<P>> {
934    let room_id = room_id.into();
935    let hash = server_record_hash(
936        &room_id,
937        seq,
938        pre_hash.as_deref(),
939        &envelope.hash,
940        received_at,
941    )?;
942    Ok(ServerRecord {
943        room_id,
944        seq,
945        pre_hash,
946        hash,
947        received_at,
948        envelope,
949    })
950}
951
952pub fn verify_server_record<P>(record: &ServerRecord<P>) -> Result<()>
953where
954    P: Serialize,
955{
956    let expected = server_record_hash(
957        &record.room_id,
958        record.seq,
959        record.pre_hash.as_deref(),
960        &record.envelope.hash,
961        record.received_at,
962    )?;
963    if record.hash == expected {
964        Ok(())
965    } else {
966        Err(SdkError::InvalidEventHash {
967            expected,
968            actual: record.hash.clone(),
969        })
970    }
971}
972
973pub fn verify_server_record_chain<P>(records: &[ServerRecord<P>]) -> Result<()>
974where
975    P: Serialize,
976{
977    let mut previous: Option<&ServerRecord<P>> = None;
978    for record in records {
979        verify_server_record(record)?;
980        if let Some(previous) = previous {
981            if record.seq != previous.seq + 1 {
982                return Err(SdkError::InvalidPayload(
983                    "seq must increase by 1".to_owned(),
984                ));
985            }
986            if record.pre_hash.as_deref() != Some(previous.hash.as_str()) {
987                return Err(SdkError::InvalidPayload("pre_hash mismatch".to_owned()));
988            }
989        } else if record.seq != 1 {
990            return Err(SdkError::InvalidPayload("first seq must be 1".to_owned()));
991        } else if record.pre_hash.is_some() {
992            return Err(SdkError::InvalidPayload(
993                "first pre_hash must be null".to_owned(),
994            ));
995        }
996        previous = Some(record);
997    }
998    Ok(())
999}
1000
1001pub fn archive_events_digest<P>(records: &[ServerRecord<P>]) -> Result<String>
1002where
1003    P: Serialize,
1004{
1005    hash_canonical_json(records)
1006}
1007
1008#[derive(Clone, Debug, Default, PartialEq, Eq)]
1009pub struct PermissionContext {
1010    pub role: Option<Role>,
1011    pub is_creator: bool,
1012    pub join_request_approved: bool,
1013    pub moderator_authorized: bool,
1014    pub expert_policy_allowed: bool,
1015    pub participant_policy_allowed: bool,
1016    pub observer_steering_allowed: bool,
1017    pub observer_poll_vote_allowed: bool,
1018}
1019
1020impl PermissionContext {
1021    pub fn for_role(role: Role) -> Self {
1022        Self {
1023            role: Some(role),
1024            ..Self::default()
1025        }
1026    }
1027
1028    pub fn creator(role: Option<Role>) -> Self {
1029        Self {
1030            role,
1031            is_creator: true,
1032            ..Self::default()
1033        }
1034    }
1035}
1036
1037#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
1038pub struct StateWriteOptions {
1039    pub post_end_reaction_allowed: bool,
1040}
1041
1042pub fn can_submit_event(event_type: &str, context: &PermissionContext) -> bool {
1043    if event_type == event_type::ROOM_CREATE {
1044        return true;
1045    }
1046    if event_type == event_type::ROOM_JOIN {
1047        return context.join_request_approved;
1048    }
1049
1050    if context.is_creator {
1051        return is_known_event_type(event_type);
1052    }
1053
1054    match context.role {
1055        Some(Role::Moderator) => moderator_can_submit(event_type, context.moderator_authorized),
1056        Some(Role::Expert) => speaker_can_submit(event_type, context.expert_policy_allowed),
1057        Some(Role::Participant) => {
1058            speaker_can_submit(event_type, context.participant_policy_allowed)
1059        }
1060        Some(Role::Observer) => observer_can_submit(event_type, context),
1061        None => false,
1062    }
1063}
1064
1065pub fn can_write_in_state(event_type: &str, state: RoomState, options: StateWriteOptions) -> bool {
1066    match state {
1067        RoomState::Scheduled => matches!(
1068            event_type,
1069            event_type::ROOM_JOIN | event_type::ROOM_JOIN_REVIEW | event_type::ROOM_CANCEL
1070        ),
1071        RoomState::Active => {
1072            event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
1073        }
1074        RoomState::Ended => {
1075            options.post_end_reaction_allowed && event_type == event_type::REACTION_CREATE
1076        }
1077        RoomState::Cancelled => false,
1078    }
1079}
1080
1081pub fn can_accept_room_write(
1082    event_type: &str,
1083    state: RoomState,
1084    permission: &PermissionContext,
1085    state_options: StateWriteOptions,
1086) -> bool {
1087    can_submit_event(event_type, permission) && can_write_in_state(event_type, state, state_options)
1088}
1089
1090pub fn validate_room_write(
1091    event_type: &str,
1092    state: RoomState,
1093    permission: &PermissionContext,
1094    state_options: StateWriteOptions,
1095) -> Result<()> {
1096    if can_accept_room_write(event_type, state, permission, state_options) {
1097        Ok(())
1098    } else {
1099        Err(SdkError::PermissionDenied)
1100    }
1101}
1102
1103fn moderator_can_submit(event_type: &str, moderator_authorized: bool) -> bool {
1104    matches!(
1105        event_type,
1106        event_type::ROOM_JOIN_REVIEW
1107            | event_type::ROOM_CLOSE
1108            | event_type::MESSAGE_CREATE
1109            | event_type::SOURCE_ADD
1110            | event_type::TURN_UPDATE
1111            | event_type::QUESTION_CREATE
1112            | event_type::ROOM_STEER
1113            | event_type::MAP_UPDATE
1114            | event_type::ARTIFACT_CREATE
1115            | event_type::SESSION_OFFER
1116            | event_type::SESSION_ANSWER
1117            | event_type::SESSION_CANDIDATE
1118            | event_type::SESSION_CLOSE
1119            | event_type::MESSAGE_PROPOSAL_CREATE
1120            | event_type::MESSAGE_POLL_CREATE
1121            | event_type::MESSAGE_POLL_VOTE
1122            | event_type::MESSAGE_RESOLUTION_CREATE
1123            | event_type::REACTION_CREATE
1124            | event_type::ROOM_LEAVE
1125    ) || (moderator_authorized
1126        && matches!(
1127            event_type,
1128            event_type::ROOM_MEMBER_ROLE_UPDATE | event_type::ROOM_CANCEL
1129        ))
1130}
1131
1132fn speaker_can_submit(event_type: &str, policy_allowed: bool) -> bool {
1133    matches!(
1134        event_type,
1135        event_type::MESSAGE_CREATE
1136            | event_type::SOURCE_ADD
1137            | event_type::ROOM_STEER
1138            | event_type::MESSAGE_PROPOSAL_CREATE
1139            | event_type::MESSAGE_POLL_CREATE
1140            | event_type::MESSAGE_POLL_VOTE
1141            | event_type::SESSION_OFFER
1142            | event_type::SESSION_ANSWER
1143            | event_type::SESSION_CANDIDATE
1144            | event_type::SESSION_CLOSE
1145            | event_type::REACTION_CREATE
1146            | event_type::ROOM_LEAVE
1147    ) || (policy_allowed
1148        && matches!(
1149            event_type,
1150            event_type::QUESTION_CREATE
1151                | event_type::MAP_UPDATE
1152                | event_type::ARTIFACT_CREATE
1153                | event_type::MESSAGE_RESOLUTION_CREATE
1154        ))
1155}
1156
1157fn observer_can_submit(event_type: &str, context: &PermissionContext) -> bool {
1158    matches!(
1159        event_type,
1160        event_type::REACTION_CREATE | event_type::ROOM_LEAVE
1161    ) || (context.observer_steering_allowed && event_type == event_type::ROOM_STEER)
1162        || (context.observer_poll_vote_allowed && event_type == event_type::MESSAGE_POLL_VOTE)
1163}
1164
1165fn is_known_event_type(event_type: &str) -> bool {
1166    matches!(
1167        event_type,
1168        event_type::ROOM_CREATE
1169            | event_type::ROOM_JOIN
1170            | event_type::ROOM_JOIN_REVIEW
1171            | event_type::ROOM_LEAVE
1172            | event_type::ROOM_MEMBER_ROLE_UPDATE
1173            | event_type::ROOM_CLOSE
1174            | event_type::ROOM_CANCEL
1175            | event_type::MESSAGE_CREATE
1176            | event_type::REACTION_CREATE
1177            | event_type::MESSAGE_PROPOSAL_CREATE
1178            | event_type::MESSAGE_POLL_CREATE
1179            | event_type::MESSAGE_POLL_VOTE
1180            | event_type::MESSAGE_RESOLUTION_CREATE
1181            | event_type::SOURCE_ADD
1182            | event_type::TURN_UPDATE
1183            | event_type::QUESTION_CREATE
1184            | event_type::ROOM_STEER
1185            | event_type::MAP_UPDATE
1186            | event_type::ARTIFACT_CREATE
1187            | event_type::SESSION_OFFER
1188            | event_type::SESSION_ANSWER
1189            | event_type::SESSION_CANDIDATE
1190            | event_type::SESSION_CLOSE
1191    )
1192}
1193
1194fn validate_session_id(session_id: &str) -> Result<()> {
1195    if session_id.trim().is_empty() {
1196        Err(SdkError::InvalidPayload(
1197            "session_id is required".to_owned(),
1198        ))
1199    } else {
1200        Ok(())
1201    }
1202}
1203
1204fn validate_session_description(
1205    description: &SessionDescription,
1206    expected_type: SessionDescriptionType,
1207) -> Result<()> {
1208    if description.kind != expected_type {
1209        return Err(SdkError::InvalidPayload(format!(
1210            "session description type must be {}",
1211            match expected_type {
1212                SessionDescriptionType::Offer => "offer",
1213                SessionDescriptionType::Answer => "answer",
1214            }
1215        )));
1216    }
1217    if description.sdp.trim().is_empty() {
1218        return Err(SdkError::InvalidPayload(
1219            "session description sdp is required".to_owned(),
1220        ));
1221    }
1222    Ok(())
1223}
1224
1225fn validate_session_transfers(transfers: &[SessionDataTransfer]) -> Result<()> {
1226    for transfer in transfers {
1227        if transfer.transfer_id.trim().is_empty() {
1228            return Err(SdkError::InvalidPayload(
1229                "transfer_id is required".to_owned(),
1230            ));
1231        }
1232    }
1233    Ok(())
1234}
1235
1236fn hash_canonical_json<T>(value: &T) -> Result<String>
1237where
1238    T: Serialize + ?Sized,
1239{
1240    let bytes = serde_jcs::to_vec(value).map_err(|err| SdkError::CanonicalJson(err.to_string()))?;
1241    let digest = Sha3_256::digest(bytes);
1242    Ok(URL_SAFE_NO_PAD.encode(digest))
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248    use crate::identity::AgentSigner;
1249    use serde_json::json;
1250
1251    #[test]
1252    fn validates_room_create_without_room_id() {
1253        let signer = AgentSigner::from_seed([14; 32]);
1254        let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1255        let event = Event::new(
1256            PROTOCOL,
1257            event_type::ROOM_CREATE,
1258            signer.agent_id(),
1259            100,
1260            1,
1261            payload,
1262        );
1263        let envelope = signer.sign_event(event).unwrap();
1264
1265        validate_discourse_envelope(&envelope).unwrap();
1266        validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
1267    }
1268
1269    #[test]
1270    fn rejects_room_event_without_room_id() {
1271        let signer = AgentSigner::from_seed([15; 32]);
1272        let event = Event::new(
1273            PROTOCOL,
1274            event_type::MESSAGE_CREATE,
1275            signer.agent_id(),
1276            100,
1277            1,
1278            MessageCreatePayload::text("hello"),
1279        );
1280        let envelope = signer.sign_event(event).unwrap();
1281
1282        assert!(matches!(
1283            validate_discourse_envelope(&envelope),
1284            Err(SdkError::MissingRoomId)
1285        ));
1286    }
1287
1288    #[test]
1289    fn applies_permission_matrix() {
1290        let observer = PermissionContext::for_role(Role::Observer);
1291        assert!(can_submit_event(event_type::REACTION_CREATE, &observer));
1292        assert!(!can_submit_event(event_type::MESSAGE_CREATE, &observer));
1293        assert!(!can_submit_event(event_type::ROOM_JOIN, &observer));
1294
1295        let approved_join = PermissionContext {
1296            join_request_approved: true,
1297            ..PermissionContext::default()
1298        };
1299        assert!(can_submit_event(event_type::ROOM_JOIN, &approved_join));
1300
1301        let mut moderator = PermissionContext::for_role(Role::Moderator);
1302        assert!(can_submit_event(event_type::ROOM_JOIN_REVIEW, &moderator));
1303        assert!(!can_submit_event(event_type::ROOM_CANCEL, &moderator));
1304        moderator.moderator_authorized = true;
1305        assert!(can_submit_event(event_type::ROOM_CANCEL, &moderator));
1306
1307        let participant = PermissionContext::for_role(Role::Participant);
1308        assert!(can_submit_event(event_type::SESSION_OFFER, &participant));
1309        assert!(!can_submit_event(
1310            event_type::ROOM_JOIN_REVIEW,
1311            &participant
1312        ));
1313        assert!(!can_submit_event(event_type::SESSION_CANDIDATE, &observer));
1314    }
1315
1316    #[test]
1317    fn applies_state_restrictions() {
1318        let participant = PermissionContext::for_role(Role::Participant);
1319        assert!(can_accept_room_write(
1320            event_type::MESSAGE_CREATE,
1321            RoomState::Active,
1322            &participant,
1323            StateWriteOptions::default()
1324        ));
1325        assert!(!can_accept_room_write(
1326            event_type::MESSAGE_CREATE,
1327            RoomState::Scheduled,
1328            &participant,
1329            StateWriteOptions::default()
1330        ));
1331        assert!(!can_accept_room_write(
1332            event_type::REACTION_CREATE,
1333            RoomState::Ended,
1334            &participant,
1335            StateWriteOptions::default()
1336        ));
1337        assert!(can_accept_room_write(
1338            event_type::ROOM_JOIN_REVIEW,
1339            RoomState::Scheduled,
1340            &PermissionContext::for_role(Role::Moderator),
1341            StateWriteOptions::default()
1342        ));
1343        assert!(can_accept_room_write(
1344            event_type::ROOM_JOIN,
1345            RoomState::Scheduled,
1346            &PermissionContext {
1347                join_request_approved: true,
1348                ..PermissionContext::default()
1349            },
1350            StateWriteOptions::default()
1351        ));
1352        assert!(can_accept_room_write(
1353            event_type::REACTION_CREATE,
1354            RoomState::Ended,
1355            &participant,
1356            StateWriteOptions {
1357                post_end_reaction_allowed: true,
1358            }
1359        ));
1360    }
1361
1362    #[test]
1363    fn validates_room_creation_payloads() {
1364        let mut payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
1365        payload.policy = Some(RoomPolicy {
1366            max_participants: Some(2),
1367            ..RoomPolicy::default()
1368        });
1369        validate_room_create_payload(&payload).unwrap();
1370
1371        let empty_topic = RoomCreatePayload::new(" ", Visibility::Public, 1000, 2000);
1372        assert!(matches!(
1373            validate_room_create_payload(&empty_topic),
1374            Err(SdkError::InvalidPayload(_))
1375        ));
1376
1377        let invalid_time = RoomCreatePayload::new("Research room", Visibility::Public, 2000, 1000);
1378        assert!(matches!(
1379            validate_room_create_payload(&invalid_time),
1380            Err(SdkError::InvalidPayload(_))
1381        ));
1382    }
1383
1384    #[test]
1385    fn validates_poll_payloads_and_votes() {
1386        let poll = PollCreatePayload {
1387            poll_id: "poll_review_order".to_owned(),
1388            question: "Which review order?".to_owned(),
1389            options: vec![
1390                PollOption {
1391                    id: "a".to_owned(),
1392                    label: "Correctness first".to_owned(),
1393                    description: None,
1394                },
1395                PollOption {
1396                    id: "b".to_owned(),
1397                    label: "Security first".to_owned(),
1398                    description: None,
1399                },
1400            ],
1401            min_choices: Some(1),
1402            max_choices: Some(1),
1403            closes_at: None,
1404            references: Vec::new(),
1405            extra: BTreeMap::new(),
1406        };
1407
1408        validate_poll_create_payload(&poll).unwrap();
1409        validate_poll_vote_payload(
1410            &PollVotePayload {
1411                event_id: "evt".to_owned(),
1412                option_ids: vec!["a".to_owned()],
1413            },
1414            &poll,
1415            None,
1416        )
1417        .unwrap();
1418        assert!(matches!(
1419            validate_poll_vote_payload(
1420                &PollVotePayload {
1421                    event_id: "evt".to_owned(),
1422                    option_ids: vec!["a".to_owned(), "b".to_owned()],
1423                },
1424                &poll,
1425                None,
1426            ),
1427            Err(SdkError::InvalidPayload(_))
1428        ));
1429
1430        let mut duplicate = poll.clone();
1431        duplicate.options[1].id = "a".to_owned();
1432        assert!(matches!(
1433            validate_poll_create_payload(&duplicate),
1434            Err(SdkError::InvalidPayload(_))
1435        ));
1436    }
1437
1438    #[test]
1439    fn validates_webrtc_session_payloads() {
1440        let offer = SessionOfferPayload {
1441            session_id: "sess_live_review".to_owned(),
1442            session_type: SessionType::Webrtc,
1443            media: vec![
1444                SessionMediaKind::Audio,
1445                SessionMediaKind::Video,
1446                SessionMediaKind::File,
1447            ],
1448            description: SessionDescription {
1449                kind: SessionDescriptionType::Offer,
1450                sdp: "v=0\r\n...".to_owned(),
1451            },
1452            to: Vec::new(),
1453            topology: Some(SessionTopology::PeerToPeer),
1454            transfers: vec![SessionDataTransfer {
1455                transfer_id: "file_1".to_owned(),
1456                file_name: Some("trace.har".to_owned()),
1457                size_bytes: Some(1024),
1458                mime_type: Some("application/json".to_owned()),
1459                content_digest: Some("sha256:abc".to_owned()),
1460            }],
1461            expires_at: None,
1462            references: Vec::new(),
1463            extra: BTreeMap::new(),
1464        };
1465        validate_session_offer_payload(&offer).unwrap();
1466
1467        validate_session_answer_payload(&SessionAnswerPayload {
1468            session_id: "sess_live_review".to_owned(),
1469            offer_event_id: "evt_offer".to_owned(),
1470            description: SessionDescription {
1471                kind: SessionDescriptionType::Answer,
1472                sdp: "v=0\r\n...".to_owned(),
1473            },
1474            accepted_media: vec![SessionMediaKind::Audio, SessionMediaKind::File],
1475            transfers: Vec::new(),
1476            extra: BTreeMap::new(),
1477        })
1478        .unwrap();
1479
1480        validate_session_candidate_payload(&SessionCandidatePayload {
1481            session_id: "sess_live_review".to_owned(),
1482            candidate: Some(SessionIceCandidate {
1483                candidate: "candidate:1 1 udp 1 127.0.0.1 3478 typ host".to_owned(),
1484                sdp_mid: None,
1485                sdp_mline_index: None,
1486                username_fragment: None,
1487            }),
1488            target: None,
1489            end_of_candidates: None,
1490            extra: BTreeMap::new(),
1491        })
1492        .unwrap();
1493
1494        validate_session_candidate_payload(&SessionCandidatePayload {
1495            session_id: "sess_live_review".to_owned(),
1496            candidate: None,
1497            target: None,
1498            end_of_candidates: Some(true),
1499            extra: BTreeMap::new(),
1500        })
1501        .unwrap();
1502
1503        let mut invalid_offer = offer;
1504        invalid_offer.description.kind = SessionDescriptionType::Answer;
1505        assert!(matches!(
1506            validate_session_offer_payload(&invalid_offer),
1507            Err(SdkError::InvalidPayload(_))
1508        ));
1509
1510        assert!(matches!(
1511            validate_session_candidate_payload(&SessionCandidatePayload {
1512                session_id: "sess_live_review".to_owned(),
1513                candidate: None,
1514                target: None,
1515                end_of_candidates: None,
1516                extra: BTreeMap::new(),
1517            }),
1518            Err(SdkError::InvalidPayload(_))
1519        ));
1520    }
1521
1522    #[test]
1523    fn builds_and_verifies_server_record_chains() {
1524        let signer = AgentSigner::from_seed([18; 32]);
1525        let envelope1 = signer
1526            .sign_event(Event::new(
1527                PROTOCOL,
1528                event_type::ROOM_CREATE,
1529                signer.agent_id(),
1530                100,
1531                1,
1532                json!({
1533                    "topic": "Research room",
1534                    "visibility": "public",
1535                    "start_time": 1000,
1536                    "end_time": 2000
1537                }),
1538            ))
1539            .unwrap();
1540        let record1 = build_server_record("room123", 1, None, 110, envelope1).unwrap();
1541
1542        let envelope2 = signer
1543            .sign_event(discourse_event(
1544                event_type::MESSAGE_CREATE,
1545                signer.agent_id(),
1546                120,
1547                2,
1548                "room123",
1549                json!({"content_type": "text/plain", "content": "hello"}),
1550            ))
1551            .unwrap();
1552        let record2 =
1553            build_server_record("room123", 2, Some(record1.hash.clone()), 130, envelope2).unwrap();
1554
1555        assert_eq!(
1556            record1.hash,
1557            server_record_hash("room123", 1, None, &record1.envelope.hash, 110).unwrap()
1558        );
1559        verify_server_record(&record1).unwrap();
1560        verify_server_record_chain(&[record1.clone(), record2.clone()]).unwrap();
1561        assert_eq!(
1562            archive_events_digest(&[record1.clone(), record2.clone()])
1563                .unwrap()
1564                .len(),
1565            43
1566        );
1567        assert!(verify_server_record_chain(&[record2]).is_err());
1568
1569        let broken = ServerRecord {
1570            pre_hash: Some("bad".to_owned()),
1571            ..record1
1572        };
1573        assert!(verify_server_record_chain(&[broken]).is_err());
1574    }
1575}