1use std::collections::BTreeMap;
2
3use serde::{Deserialize, Serialize};
4use serde_json::Value;
5
6use crate::error::{Result, SdkError};
7use crate::identity::{verify_envelope, AgentId, Envelope, Event};
8
9pub const PROTOCOL: &str = "agent-discourse/1.0";
10pub const LEGACY_PROTOCOL: &str = "adp/1.0";
11
12pub mod event_type {
13 pub const ROOM_CREATE: &str = "room.create";
14 pub const ROOM_JOIN: &str = "room.join";
15 pub const ROOM_JOIN_REVIEW: &str = "room.join.review";
16 pub const ROOM_LEAVE: &str = "room.leave";
17 pub const ROOM_MEMBER_ROLE_UPDATE: &str = "room.member.role.update";
18 pub const ROOM_CLOSE: &str = "room.close";
19 pub const ROOM_CANCEL: &str = "room.cancel";
20 pub const MESSAGE_CREATE: &str = "message.create";
21 pub const REACTION_CREATE: &str = "reaction.create";
22 pub const MESSAGE_PROPOSAL_CREATE: &str = "message.proposal.create";
23 pub const MESSAGE_POLL_CREATE: &str = "message.poll.create";
24 pub const MESSAGE_POLL_VOTE: &str = "message.poll.vote";
25 pub const MESSAGE_RESOLUTION_CREATE: &str = "message.resolution.create";
26 pub const SOURCE_ADD: &str = "source.add";
27 pub const TURN_UPDATE: &str = "turn.update";
28 pub const QUESTION_CREATE: &str = "question.create";
29 pub const ROOM_STEER: &str = "room.steer";
30 pub const MAP_UPDATE: &str = "map.update";
31 pub const ARTIFACT_CREATE: &str = "artifact.create";
32}
33
34#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
35#[serde(rename_all = "snake_case")]
36pub enum RoomState {
37 Scheduled,
38 Active,
39 Ended,
40 Cancelled,
41}
42
43#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "snake_case")]
45pub enum Visibility {
46 Public,
47 Restricted,
48 Private,
49}
50
51#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
52#[serde(rename_all = "snake_case")]
53pub enum TurnPolicy {
54 Free,
55 RoundRobin,
56 ModeratorLed,
57}
58
59#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
60#[serde(rename_all = "snake_case")]
61pub enum Role {
62 Moderator,
63 Expert,
64 Participant,
65 Observer,
66}
67
68#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
69#[serde(rename_all = "snake_case")]
70pub enum JoinRequestStatus {
71 Pending,
72 Approved,
73 Rejected,
74 Expired,
75}
76
77#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
78#[serde(rename_all = "snake_case")]
79pub enum MessageIntent {
80 Question,
81 Answer,
82 Clarification,
83 Critique,
84 Synthesis,
85 FollowUp,
86 Other,
87}
88
89#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
90#[serde(rename_all = "snake_case")]
91pub enum MapOperation {
92 UpsertNode,
93 MoveNode,
94 DeleteNode,
95 MergeNodes,
96 ReplaceSnapshot,
97 MarkResolved,
98}
99
100#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
101pub struct RoomCreatePayload {
102 pub topic: String,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub agenda: Option<String>,
105 pub visibility: Visibility,
106 pub start_time: i64,
107 pub end_time: i64,
108 #[serde(default, skip_serializing_if = "Vec::is_empty")]
109 pub tags: Vec<String>,
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub language: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub policy: Option<RoomPolicy>,
114 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
115 pub extensions: BTreeMap<String, Value>,
116 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
117 pub extra: BTreeMap<String, Value>,
118}
119
120#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
121pub struct RoomPolicy {
122 #[serde(default, skip_serializing_if = "Option::is_none")]
123 pub turn_policy: Option<TurnPolicy>,
124 #[serde(default, skip_serializing_if = "Vec::is_empty")]
125 pub moderator_agent_ids: Vec<AgentId>,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub max_participants: Option<u32>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub observer_allowed: Option<bool>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub observer_steering_allowed: Option<bool>,
132 #[serde(default, flatten)]
133 pub extra: BTreeMap<String, Value>,
134}
135
136impl RoomPolicy {
137 pub fn new() -> Self {
138 Self::default()
139 }
140}
141
142impl RoomCreatePayload {
143 pub fn new(
144 topic: impl Into<String>,
145 visibility: Visibility,
146 start_time: i64,
147 end_time: i64,
148 ) -> Self {
149 Self {
150 topic: topic.into(),
151 agenda: None,
152 visibility,
153 start_time,
154 end_time,
155 tags: Vec::new(),
156 language: None,
157 policy: None,
158 extensions: BTreeMap::new(),
159 extra: BTreeMap::new(),
160 }
161 }
162}
163
164#[derive(Clone, Debug, Serialize, Deserialize)]
165pub struct ServerRecord<P = Value> {
166 pub room_id: String,
167 pub seq: u64,
168 #[serde(default, skip_serializing_if = "Option::is_none")]
169 pub pre_hash: Option<String>,
170 pub hash: String,
171 pub received_at: i64,
172 pub envelope: Envelope<P>,
173}
174
175#[derive(Clone, Debug, Serialize, Deserialize)]
176pub struct RoomResponse {
177 pub id: String,
178 pub status: RoomState,
179 pub url: String,
180 pub seq: u64,
181 #[serde(default, skip_serializing_if = "Option::is_none")]
182 pub pre_hash: Option<String>,
183 pub hash: String,
184 pub received_at: i64,
185 pub envelope: Option<Envelope<RoomCreatePayload>>,
186}
187
188#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
189pub struct RoomJoinPayload {
190 pub request_id: String,
191 pub role: Role,
192 #[serde(default, skip_serializing_if = "Option::is_none")]
193 pub perspective: Option<String>,
194}
195
196#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
197pub struct RoomJoinRequestPayload {
198 pub requested_role: Role,
199 #[serde(default, skip_serializing_if = "Option::is_none")]
200 pub perspective: Option<String>,
201 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub reason: Option<String>,
203 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
204 pub extra: BTreeMap<String, Value>,
205}
206
207impl RoomJoinRequestPayload {
208 pub fn new(requested_role: Role) -> Self {
209 Self {
210 requested_role,
211 perspective: None,
212 reason: None,
213 extra: BTreeMap::new(),
214 }
215 }
216}
217
218#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
219pub struct RoomJoinRequest {
220 pub id: String,
221 pub room_id: String,
222 pub applicant: AgentId,
223 pub requested_role: Role,
224 #[serde(default, skip_serializing_if = "Option::is_none")]
225 pub approved_role: Option<Role>,
226 #[serde(default, skip_serializing_if = "Option::is_none")]
227 pub perspective: Option<String>,
228 pub status: JoinRequestStatus,
229 #[serde(default, skip_serializing_if = "Option::is_none")]
230 pub request_reason: Option<String>,
231 #[serde(default, skip_serializing_if = "Option::is_none")]
232 pub review_reason: Option<String>,
233 pub created_at: i64,
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub reviewed_by: Option<AgentId>,
236 #[serde(default, skip_serializing_if = "Option::is_none")]
237 pub reviewed_at: Option<i64>,
238 #[serde(default, skip_serializing_if = "Option::is_none")]
239 pub expires_at: Option<i64>,
240 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
241 pub extra: BTreeMap<String, Value>,
242}
243
244#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)]
245#[serde(rename_all = "snake_case")]
246pub enum JoinDecision {
247 Approve,
248 Reject,
249}
250
251#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
252pub struct RoomJoinReviewPayload {
253 pub request_id: String,
254 pub member: AgentId,
255 pub decision: JoinDecision,
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub role: Option<Role>,
258 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub reason: Option<String>,
260}
261
262#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
263pub struct RoomLeavePayload {
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub reason: Option<String>,
266}
267
268#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
269pub struct RoleUpdatePayload {
270 pub member: AgentId,
271 pub role: Role,
272 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub reason: Option<String>,
274}
275
276#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
277pub struct MessageCreatePayload {
278 pub content_type: String,
279 pub content: Value,
280 #[serde(default, skip_serializing_if = "Vec::is_empty")]
281 pub references: Vec<String>,
282}
283
284impl MessageCreatePayload {
285 pub fn new(content_type: impl Into<String>, content: Value) -> Self {
286 Self {
287 content_type: content_type.into(),
288 content,
289 references: Vec::new(),
290 }
291 }
292
293 pub fn text(text: impl Into<String>) -> Self {
294 Self::new("text/plain", Value::String(text.into()))
295 }
296
297 pub fn markdown(markdown: impl Into<String>) -> Self {
298 Self::new("text/markdown", Value::String(markdown.into()))
299 }
300}
301
302#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
303pub struct ReactionCreatePayload {
304 pub event_id: String,
305 pub reaction: String,
306 #[serde(default, skip_serializing_if = "Option::is_none")]
307 pub score: Option<f64>,
308}
309
310impl ReactionCreatePayload {
311 pub fn new(event_id: impl Into<String>, reaction: impl Into<String>) -> Self {
312 Self {
313 event_id: event_id.into(),
314 reaction: reaction.into(),
315 score: None,
316 }
317 }
318}
319
320#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
321pub struct SourceAddPayload {
322 pub source_type: String,
323 pub uri: String,
324 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub title: Option<String>,
326 #[serde(default, skip_serializing_if = "Option::is_none")]
327 pub retrieved_at: Option<i64>,
328 #[serde(default, skip_serializing_if = "Option::is_none")]
329 pub content_digest: Option<String>,
330 #[serde(default, skip_serializing_if = "Option::is_none")]
331 pub excerpt: Option<String>,
332 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
333 pub extra: BTreeMap<String, Value>,
334}
335
336#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
337pub struct TurnUpdatePayload {
338 pub turn_id: u64,
339 pub speaker: AgentId,
340 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub intent: Option<MessageIntent>,
342 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub topic: Option<String>,
344 #[serde(default, skip_serializing_if = "Option::is_none")]
345 pub expires_at: Option<i64>,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
347 pub reason: Option<String>,
348}
349
350#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
351pub struct QuestionGeneratePayload {
352 pub question: String,
353 #[serde(default, skip_serializing_if = "Vec::is_empty")]
354 pub target_perspectives: Vec<String>,
355 #[serde(default, skip_serializing_if = "Option::is_none")]
356 pub basis: Option<String>,
357 #[serde(default, skip_serializing_if = "Vec::is_empty")]
358 pub source_event_ids: Vec<String>,
359 #[serde(default, skip_serializing_if = "Vec::is_empty")]
360 pub references: Vec<String>,
361 #[serde(default, skip_serializing_if = "Option::is_none")]
362 pub priority: Option<f64>,
363}
364
365#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
366pub struct DiscourseSteerPayload {
367 pub instruction: String,
368 pub target: String,
369 #[serde(default, skip_serializing_if = "Option::is_none")]
370 pub priority: Option<f64>,
371 #[serde(default, skip_serializing_if = "Vec::is_empty")]
372 pub references: Vec<String>,
373}
374
375#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
376#[serde(rename_all = "snake_case")]
377pub enum MapNodeStatus {
378 Open,
379 Resolved,
380 Closed,
381}
382
383#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
384pub struct MapNode {
385 pub id: String,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
387 pub parent_id: Option<String>,
388 pub title: String,
389 #[serde(default, skip_serializing_if = "Option::is_none")]
390 pub summary: Option<String>,
391 #[serde(default, skip_serializing_if = "Option::is_none")]
392 pub status: Option<MapNodeStatus>,
393 #[serde(default, skip_serializing_if = "Vec::is_empty")]
394 pub source_event_ids: Vec<String>,
395 #[serde(default, skip_serializing_if = "Vec::is_empty")]
396 pub discussion_event_ids: Vec<String>,
397 #[serde(default, skip_serializing_if = "Vec::is_empty")]
398 pub children: Vec<MapNode>,
399 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
400 pub extra: BTreeMap<String, Value>,
401}
402
403#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
404pub struct MapUpdatePayload {
405 pub operation: MapOperation,
406 #[serde(default, skip_serializing_if = "Option::is_none")]
407 pub node: Option<MapNode>,
408 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
409 pub extra: BTreeMap<String, Value>,
410 #[serde(default, flatten)]
411 pub extensions: BTreeMap<String, Value>,
412}
413
414#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
415pub struct ArtifactCreatePayload {
416 pub artifact_id: String,
417 pub format: String,
418 pub title: String,
419 pub uri: String,
420 #[serde(default, skip_serializing_if = "Option::is_none")]
421 pub content_digest: Option<String>,
422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
423 pub source_event_ids: Vec<String>,
424 #[serde(default, skip_serializing_if = "Vec::is_empty")]
425 pub discussion_event_ids: Vec<String>,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub map_event_id: Option<String>,
428}
429
430#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
431pub struct RoomClosePayload {
432 #[serde(default, skip_serializing_if = "Option::is_none")]
433 pub reason: Option<String>,
434}
435
436#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
437pub struct RoomCancelPayload {
438 #[serde(default, skip_serializing_if = "Option::is_none")]
439 pub reason: Option<String>,
440}
441
442#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
443pub struct ProfileResolverMetadata {
444 pub mode: String,
445 #[serde(default, skip_serializing_if = "Option::is_none")]
446 pub service: Option<String>,
447 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub protocol: Option<String>,
449}
450
451#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
452pub struct DiscourseProtocolDiscovery {
453 pub protocol: String,
454 pub host: String,
455 #[serde(default, skip_serializing_if = "Vec::is_empty")]
456 pub features: Vec<String>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
458 pub profile: Option<ProfileResolverMetadata>,
459 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
460 pub endpoints: BTreeMap<String, String>,
461}
462
463#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
464pub struct MapSnapshotRef {
465 pub event_id: String,
466 pub digest: String,
467}
468
469#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
470pub struct ArtifactManifest {
471 pub artifact_id: String,
472 #[serde(rename = "type")]
473 pub kind: String,
474 pub format: String,
475 pub uri: String,
476 #[serde(default, skip_serializing_if = "Option::is_none")]
477 pub content_digest: Option<String>,
478}
479
480#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
481pub struct ArchiveManifest {
482 pub protocol: String,
483 #[serde(rename = "type")]
484 pub kind: String,
485 pub host: String,
486 pub room_id: String,
487 pub url: String,
488 pub generated_at: i64,
489 pub event_count: u64,
490 pub first_seq: u64,
491 pub last_seq: u64,
492 pub last_hash: String,
493 pub events_sha3_256: String,
494 pub archive_root: String,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
496 pub map_snapshot: Option<MapSnapshotRef>,
497 #[serde(default, skip_serializing_if = "Vec::is_empty")]
498 pub artifacts: Vec<ArtifactManifest>,
499 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub discourse_trace_quality_score: Option<f64>,
501 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
502 pub formats: BTreeMap<String, String>,
503}
504
505pub fn room_create_event(
506 actor: AgentId,
507 created_at: i64,
508 nonce: u64,
509 payload: RoomCreatePayload,
510) -> Event<RoomCreatePayload> {
511 Event::new(
512 PROTOCOL,
513 event_type::ROOM_CREATE,
514 actor,
515 created_at,
516 nonce,
517 payload,
518 )
519}
520
521pub fn discourse_event<P>(
522 kind: impl Into<String>,
523 actor: AgentId,
524 created_at: i64,
525 nonce: u64,
526 room_id: impl Into<String>,
527 payload: P,
528) -> Event<P> {
529 Event::new(PROTOCOL, kind, actor, created_at, nonce, payload).with_room_id(room_id)
530}
531
532pub fn validate_discourse_envelope<P>(
533 envelope: &Envelope<P>,
534 accept_legacy_protocol: bool,
535) -> Result<()>
536where
537 P: Serialize,
538{
539 verify_envelope(envelope)?;
540 let protocol = envelope.event.protocol.as_str();
541 let protocol_ok =
542 protocol == PROTOCOL || (accept_legacy_protocol && protocol == LEGACY_PROTOCOL);
543 if !protocol_ok {
544 return Err(SdkError::InvalidEventProtocol {
545 expected: PROTOCOL.to_owned(),
546 actual: envelope.event.protocol.clone(),
547 });
548 }
549 if event_requires_room_id(&envelope.event.kind) && envelope.event.room_id.is_none() {
550 return Err(SdkError::MissingRoomId);
551 }
552 Ok(())
553}
554
555pub fn validate_room_path<P>(envelope: &Envelope<P>, path_room_id: &str) -> Result<()> {
556 match envelope.event.room_id.as_deref() {
557 Some(actual) if actual == path_room_id => Ok(()),
558 Some(actual) => Err(SdkError::RoomIdMismatch {
559 expected: path_room_id.to_owned(),
560 actual: actual.to_owned(),
561 }),
562 None if envelope.event.kind == event_type::ROOM_CREATE => Ok(()),
563 None => Err(SdkError::MissingRoomId),
564 }
565}
566
567pub fn event_requires_room_id(event_type: &str) -> bool {
568 event_type != event_type::ROOM_CREATE
569}
570
571#[derive(Clone, Debug, Default, PartialEq, Eq)]
572pub struct PermissionContext {
573 pub role: Option<Role>,
574 pub is_creator: bool,
575 pub join_request_approved: bool,
576 pub moderator_authorized: bool,
577 pub expert_policy_allowed: bool,
578 pub participant_policy_allowed: bool,
579 pub observer_steering_allowed: bool,
580 pub observer_poll_vote_allowed: bool,
581}
582
583impl PermissionContext {
584 pub fn for_role(role: Role) -> Self {
585 Self {
586 role: Some(role),
587 ..Self::default()
588 }
589 }
590
591 pub fn creator(role: Option<Role>) -> Self {
592 Self {
593 role,
594 is_creator: true,
595 ..Self::default()
596 }
597 }
598}
599
600#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
601pub struct StateWriteOptions {
602 pub post_end_reaction_allowed: bool,
603}
604
605pub fn can_submit_event(event_type: &str, context: &PermissionContext) -> bool {
606 if event_type == event_type::ROOM_CREATE {
607 return true;
608 }
609 if event_type == event_type::ROOM_JOIN {
610 return context.join_request_approved;
611 }
612
613 if context.is_creator {
614 return is_known_event_type(event_type);
615 }
616
617 match context.role {
618 Some(Role::Moderator) => moderator_can_submit(event_type, context.moderator_authorized),
619 Some(Role::Expert) => speaker_can_submit(event_type, context.expert_policy_allowed),
620 Some(Role::Participant) => {
621 speaker_can_submit(event_type, context.participant_policy_allowed)
622 }
623 Some(Role::Observer) => observer_can_submit(event_type, context),
624 None => false,
625 }
626}
627
628pub fn can_write_in_state(event_type: &str, state: RoomState, options: StateWriteOptions) -> bool {
629 match state {
630 RoomState::Scheduled => matches!(
631 event_type,
632 event_type::ROOM_JOIN | event_type::ROOM_JOIN_REVIEW | event_type::ROOM_CANCEL
633 ),
634 RoomState::Active => {
635 event_type != event_type::ROOM_CREATE && event_type != event_type::ROOM_CANCEL
636 }
637 RoomState::Ended => {
638 options.post_end_reaction_allowed && event_type == event_type::REACTION_CREATE
639 }
640 RoomState::Cancelled => false,
641 }
642}
643
644pub fn can_accept_room_write(
645 event_type: &str,
646 state: RoomState,
647 permission: &PermissionContext,
648 state_options: StateWriteOptions,
649) -> bool {
650 can_submit_event(event_type, permission) && can_write_in_state(event_type, state, state_options)
651}
652
653pub fn validate_room_write(
654 event_type: &str,
655 state: RoomState,
656 permission: &PermissionContext,
657 state_options: StateWriteOptions,
658) -> Result<()> {
659 if can_accept_room_write(event_type, state, permission, state_options) {
660 Ok(())
661 } else {
662 Err(SdkError::PermissionDenied)
663 }
664}
665
666fn moderator_can_submit(event_type: &str, moderator_authorized: bool) -> bool {
667 matches!(
668 event_type,
669 event_type::ROOM_JOIN_REVIEW
670 | event_type::ROOM_CLOSE
671 | event_type::MESSAGE_CREATE
672 | event_type::SOURCE_ADD
673 | event_type::TURN_UPDATE
674 | event_type::QUESTION_CREATE
675 | event_type::ROOM_STEER
676 | event_type::MAP_UPDATE
677 | event_type::ARTIFACT_CREATE
678 | event_type::MESSAGE_PROPOSAL_CREATE
679 | event_type::MESSAGE_POLL_CREATE
680 | event_type::MESSAGE_POLL_VOTE
681 | event_type::MESSAGE_RESOLUTION_CREATE
682 | event_type::REACTION_CREATE
683 | event_type::ROOM_LEAVE
684 ) || (moderator_authorized
685 && matches!(
686 event_type,
687 event_type::ROOM_MEMBER_ROLE_UPDATE | event_type::ROOM_CANCEL
688 ))
689}
690
691fn speaker_can_submit(event_type: &str, policy_allowed: bool) -> bool {
692 matches!(
693 event_type,
694 event_type::MESSAGE_CREATE
695 | event_type::SOURCE_ADD
696 | event_type::ROOM_STEER
697 | event_type::MESSAGE_PROPOSAL_CREATE
698 | event_type::MESSAGE_POLL_CREATE
699 | event_type::MESSAGE_POLL_VOTE
700 | event_type::REACTION_CREATE
701 | event_type::ROOM_LEAVE
702 ) || (policy_allowed
703 && matches!(
704 event_type,
705 event_type::QUESTION_CREATE
706 | event_type::MAP_UPDATE
707 | event_type::ARTIFACT_CREATE
708 | event_type::MESSAGE_RESOLUTION_CREATE
709 ))
710}
711
712fn observer_can_submit(event_type: &str, context: &PermissionContext) -> bool {
713 matches!(
714 event_type,
715 event_type::REACTION_CREATE | event_type::ROOM_LEAVE
716 ) || (context.observer_steering_allowed && event_type == event_type::ROOM_STEER)
717 || (context.observer_poll_vote_allowed && event_type == event_type::MESSAGE_POLL_VOTE)
718}
719
720fn is_known_event_type(event_type: &str) -> bool {
721 matches!(
722 event_type,
723 event_type::ROOM_CREATE
724 | event_type::ROOM_JOIN
725 | event_type::ROOM_JOIN_REVIEW
726 | event_type::ROOM_LEAVE
727 | event_type::ROOM_MEMBER_ROLE_UPDATE
728 | event_type::ROOM_CLOSE
729 | event_type::ROOM_CANCEL
730 | event_type::MESSAGE_CREATE
731 | event_type::REACTION_CREATE
732 | event_type::MESSAGE_PROPOSAL_CREATE
733 | event_type::MESSAGE_POLL_CREATE
734 | event_type::MESSAGE_POLL_VOTE
735 | event_type::MESSAGE_RESOLUTION_CREATE
736 | event_type::SOURCE_ADD
737 | event_type::TURN_UPDATE
738 | event_type::QUESTION_CREATE
739 | event_type::ROOM_STEER
740 | event_type::MAP_UPDATE
741 | event_type::ARTIFACT_CREATE
742 )
743}
744
745#[cfg(test)]
746mod tests {
747 use super::*;
748 use crate::identity::AgentSigner;
749
750 #[test]
751 fn validates_room_create_without_room_id() {
752 let signer = AgentSigner::from_seed([14; 32]);
753 let payload = RoomCreatePayload::new("Research room", Visibility::Public, 1000, 2000);
754 let event = Event::new(
755 PROTOCOL,
756 event_type::ROOM_CREATE,
757 signer.agent_id(),
758 100,
759 1,
760 payload,
761 );
762 let envelope = signer.sign_event(event).unwrap();
763
764 validate_discourse_envelope(&envelope, false).unwrap();
765 validate_room_path(&envelope, "d8ftedhpqhsusbg001tg").unwrap();
766 }
767
768 #[test]
769 fn rejects_room_event_without_room_id() {
770 let signer = AgentSigner::from_seed([15; 32]);
771 let event = Event::new(
772 PROTOCOL,
773 event_type::MESSAGE_CREATE,
774 signer.agent_id(),
775 100,
776 1,
777 MessageCreatePayload::text("hello"),
778 );
779 let envelope = signer.sign_event(event).unwrap();
780
781 assert!(matches!(
782 validate_discourse_envelope(&envelope, false),
783 Err(SdkError::MissingRoomId)
784 ));
785 }
786
787 #[test]
788 fn applies_permission_matrix() {
789 let observer = PermissionContext::for_role(Role::Observer);
790 assert!(can_submit_event(event_type::REACTION_CREATE, &observer));
791 assert!(!can_submit_event(event_type::MESSAGE_CREATE, &observer));
792 assert!(!can_submit_event(event_type::ROOM_JOIN, &observer));
793
794 let approved_join = PermissionContext {
795 join_request_approved: true,
796 ..PermissionContext::default()
797 };
798 assert!(can_submit_event(event_type::ROOM_JOIN, &approved_join));
799
800 let mut moderator = PermissionContext::for_role(Role::Moderator);
801 assert!(can_submit_event(event_type::ROOM_JOIN_REVIEW, &moderator));
802 assert!(!can_submit_event(event_type::ROOM_CANCEL, &moderator));
803 moderator.moderator_authorized = true;
804 assert!(can_submit_event(event_type::ROOM_CANCEL, &moderator));
805
806 let participant = PermissionContext::for_role(Role::Participant);
807 assert!(!can_submit_event(
808 event_type::ROOM_JOIN_REVIEW,
809 &participant
810 ));
811 }
812
813 #[test]
814 fn applies_state_restrictions() {
815 let participant = PermissionContext::for_role(Role::Participant);
816 assert!(can_accept_room_write(
817 event_type::MESSAGE_CREATE,
818 RoomState::Active,
819 &participant,
820 StateWriteOptions::default()
821 ));
822 assert!(!can_accept_room_write(
823 event_type::MESSAGE_CREATE,
824 RoomState::Scheduled,
825 &participant,
826 StateWriteOptions::default()
827 ));
828 assert!(!can_accept_room_write(
829 event_type::REACTION_CREATE,
830 RoomState::Ended,
831 &participant,
832 StateWriteOptions::default()
833 ));
834 assert!(can_accept_room_write(
835 event_type::ROOM_JOIN_REVIEW,
836 RoomState::Scheduled,
837 &PermissionContext::for_role(Role::Moderator),
838 StateWriteOptions::default()
839 ));
840 assert!(can_accept_room_write(
841 event_type::ROOM_JOIN,
842 RoomState::Scheduled,
843 &PermissionContext {
844 join_request_approved: true,
845 ..PermissionContext::default()
846 },
847 StateWriteOptions::default()
848 ));
849 assert!(can_accept_room_write(
850 event_type::REACTION_CREATE,
851 RoomState::Ended,
852 &participant,
853 StateWriteOptions {
854 post_end_reaction_allowed: true,
855 }
856 ));
857 }
858}