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