1use async_trait::async_trait;
4use devboy_core::{
5 Error, MeetingFilter, MeetingNote, MeetingNotesProvider, MeetingSpeaker, MeetingTranscript,
6 ProviderResult, Result, TranscriptSentence,
7};
8use secrecy::{ExposeSecret, SecretString};
9use serde_json::{Value, json};
10use tracing::debug;
11
12use crate::types::*;
13
14const FIREFLIES_API_URL: &str = "https://api.fireflies.ai/graphql";
15
16const GET_TRANSCRIPTS_QUERY: &str = r#"
18query GetTranscripts(
19 $keyword: String
20 $fromDate: DateTime
21 $toDate: DateTime
22 $limit: Int
23 $skip: Int
24 $host_email: String
25 $participants: [String!]
26) {
27 transcripts(
28 keyword: $keyword
29 fromDate: $fromDate
30 toDate: $toDate
31 limit: $limit
32 skip: $skip
33 host_email: $host_email
34 participants: $participants
35 ) {
36 id
37 title
38 date
39 duration
40 host_email
41 organizer_email
42 meeting_attendees { displayName email name }
43 speakers { id name }
44 transcript_url
45 audio_url
46 video_url
47 meeting_link
48 summary {
49 keywords
50 action_items
51 topics_discussed
52 meeting_type
53 overview
54 short_summary
55 }
56 }
57}
58"#;
59
60const GET_TRANSCRIPT_QUERY: &str = r#"
62query GetTranscript($transcriptId: String!) {
63 transcript(id: $transcriptId) {
64 id
65 title
66 date
67 duration
68 speakers { id name }
69 sentences {
70 speaker_id
71 text
72 start_time
73 end_time
74 }
75 }
76}
77"#;
78
79pub struct FirefliesClient {
81 api_key: SecretString,
82 api_url: String,
83 http: reqwest::Client,
84}
85
86impl FirefliesClient {
87 pub fn new(api_key: SecretString) -> Self {
88 Self {
89 api_key,
90 api_url: FIREFLIES_API_URL.to_string(),
91 http: reqwest::Client::new(),
92 }
93 }
94
95 pub fn with_api_url(mut self, url: impl Into<String>) -> Self {
98 self.api_url = url.into();
99 self
100 }
101
102 async fn graphql<T: serde::de::DeserializeOwned>(
104 &self,
105 query: &str,
106 variables: Value,
107 ) -> Result<T> {
108 let body = json!({
109 "query": query,
110 "variables": variables,
111 });
112
113 debug!(url = %self.api_url, "fireflies graphql request");
114
115 let response = self
116 .http
117 .post(&self.api_url)
118 .header(
119 "Authorization",
120 format!("Bearer {}", self.api_key.expose_secret()),
121 )
122 .header("Content-Type", "application/json")
123 .json(&body)
124 .send()
125 .await
126 .map_err(|e| Error::Network(e.to_string()))?;
127
128 let status = response.status();
129 if status == reqwest::StatusCode::UNAUTHORIZED {
130 return Err(Error::Unauthorized("Invalid Fireflies API key".to_string()));
131 }
132 if !status.is_success() {
133 let text = response.text().await.unwrap_or_default();
134 return Err(Error::Api {
135 status: status.as_u16(),
136 message: text,
137 });
138 }
139
140 let gql_response: GraphQLResponse<T> = response
141 .json()
142 .await
143 .map_err(|e| Error::InvalidData(e.to_string()))?;
144
145 if let Some(errors) = gql_response.errors {
146 let messages: Vec<String> = errors.into_iter().map(|e| e.message).collect();
147 return Err(Error::Api {
148 status: 200,
149 message: messages.join("; "),
150 });
151 }
152
153 gql_response
154 .data
155 .ok_or_else(|| Error::InvalidData("Fireflies API returned no data".to_string()))
156 }
157}
158
159#[async_trait]
160impl MeetingNotesProvider for FirefliesClient {
161 fn provider_name(&self) -> &'static str {
162 "fireflies"
163 }
164
165 async fn get_meetings(&self, filter: MeetingFilter) -> Result<ProviderResult<MeetingNote>> {
166 let variables = build_filter_variables(&filter);
167 let data: TranscriptsData = self.graphql(GET_TRANSCRIPTS_QUERY, variables).await?;
168 Ok(data
169 .transcripts
170 .into_iter()
171 .map(convert_transcript)
172 .collect::<Vec<_>>()
173 .into())
174 }
175
176 async fn get_transcript(&self, meeting_id: &str) -> Result<MeetingTranscript> {
177 let variables = json!({ "transcriptId": meeting_id });
178 let data: TranscriptData = self.graphql(GET_TRANSCRIPT_QUERY, variables).await?;
179
180 let transcript = data.transcript.ok_or_else(|| {
181 Error::NotFound(format!("Meeting transcript not found: {meeting_id}"))
182 })?;
183
184 let speakers = transcript.speakers.as_deref().unwrap_or(&[]);
185 let speaker_map: std::collections::HashMap<String, String> = speakers
187 .iter()
188 .filter_map(|sp| {
189 let id = match &sp.id {
190 Some(Value::String(v)) => v.clone(),
191 Some(Value::Number(n)) => n.to_string(),
192 _ => return None,
193 };
194 sp.name.as_ref().map(|name| (id, name.clone()))
195 })
196 .collect();
197 let sentences = transcript
198 .sentences
199 .unwrap_or_default()
200 .into_iter()
201 .map(|s| {
202 let speaker_id = match &s.speaker_id {
203 Some(Value::String(id)) => id.clone(),
204 Some(Value::Number(n)) => n.to_string(),
205 _ => String::new(),
206 };
207 let speaker_name = if speaker_id.is_empty() {
209 None
210 } else {
211 speaker_map.get(&speaker_id).cloned()
212 };
213
214 TranscriptSentence {
215 speaker_id,
216 speaker_name,
217 text: s.text.unwrap_or_default(),
218 start_time: s.start_time.unwrap_or(0.0),
219 end_time: s.end_time.unwrap_or(0.0),
220 }
221 })
222 .collect();
223
224 Ok(MeetingTranscript {
225 meeting_id: transcript.id,
226 title: transcript.title,
227 sentences,
228 })
229 }
230
231 async fn search_meetings(
232 &self,
233 query: &str,
234 filter: MeetingFilter,
235 ) -> Result<ProviderResult<MeetingNote>> {
236 let mut variables = build_filter_variables(&filter);
237 if let Some(obj) = variables.as_object_mut() {
238 obj.insert("keyword".into(), Value::String(query.to_string()));
239 }
240 let data: TranscriptsData = self.graphql(GET_TRANSCRIPTS_QUERY, variables).await?;
241 Ok(data
242 .transcripts
243 .into_iter()
244 .map(convert_transcript)
245 .collect::<Vec<_>>()
246 .into())
247 }
248}
249
250fn build_filter_variables(filter: &MeetingFilter) -> Value {
252 let mut vars = serde_json::Map::new();
253
254 if let Some(ref keyword) = filter.keyword {
255 vars.insert("keyword".into(), Value::String(keyword.clone()));
256 }
257 if let Some(ref from) = filter.from_date {
258 vars.insert("fromDate".into(), Value::String(from.clone()));
259 }
260 if let Some(ref to) = filter.to_date {
261 vars.insert("toDate".into(), Value::String(to.clone()));
262 }
263 if let Some(ref host) = filter.host_email {
264 vars.insert("host_email".into(), Value::String(host.clone()));
265 }
266 if let Some(ref participants) = filter.participants {
267 vars.insert(
268 "participants".into(),
269 Value::Array(
270 participants
271 .iter()
272 .map(|p| Value::String(p.clone()))
273 .collect(),
274 ),
275 );
276 }
277 vars.insert(
278 "limit".into(),
279 Value::Number(filter.limit.unwrap_or(50).into()),
280 );
281 if let Some(skip) = filter.skip {
282 vars.insert("skip".into(), Value::Number(skip.into()));
283 }
284
285 Value::Object(vars)
286}
287
288fn convert_transcript(t: FirefliesTranscript) -> MeetingNote {
290 let meeting_date = t.date.and_then(|d| parse_date_value(&d));
291
292 let participants: Vec<String> = t
293 .meeting_attendees
294 .unwrap_or_default()
295 .into_iter()
296 .filter_map(|a| a.email.or(a.name).or(a.display_name))
297 .collect();
298
299 let speakers: Vec<MeetingSpeaker> = t
300 .speakers
301 .unwrap_or_default()
302 .into_iter()
303 .map(|s| {
304 let id = match &s.id {
305 Some(Value::String(v)) => v.clone(),
306 Some(Value::Number(n)) => n.to_string(),
307 _ => String::new(),
308 };
309 MeetingSpeaker {
310 id,
311 name: s.name.unwrap_or_default(),
312 }
313 })
314 .collect();
315
316 let duration_seconds = t.duration.map(|d| (d * 60.0) as u64);
318
319 let summary_ref = t.summary.as_ref();
320 let action_items = summary_ref
321 .and_then(|s| s.action_items.as_ref())
322 .map(parse_array_or_string)
323 .unwrap_or_default();
324 let keywords = summary_ref
325 .and_then(|s| s.keywords.as_ref())
326 .map(parse_array_or_string)
327 .unwrap_or_default();
328 let topics_discussed = summary_ref
329 .and_then(|s| s.topics_discussed.as_ref())
330 .map(parse_array_or_string)
331 .unwrap_or_default();
332 let meeting_type = summary_ref.and_then(|s| s.meeting_type.clone());
333 let summary_text = summary_ref.and_then(|s| s.overview.clone().or(s.short_summary.clone()));
334
335 MeetingNote {
336 id: t.id,
337 title: t.title.unwrap_or_default(),
338 meeting_date,
339 duration_seconds,
340 host_email: t.host_email,
341 organizer_email: t.organizer_email,
342 participants,
343 speakers,
344 action_items,
345 keywords,
346 topics_discussed,
347 meeting_type,
348 summary: summary_text,
349 transcript_url: t.transcript_url,
350 audio_url: t.audio_url,
351 video_url: t.video_url,
352 meeting_link: t.meeting_link,
353 }
354}
355
356fn parse_date_value(value: &Value) -> Option<String> {
358 match value {
359 Value::String(s) => Some(s.clone()),
360 Value::Number(n) => {
361 let ms = n.as_f64()?;
363 let secs = (ms / 1000.0) as i64;
364 let dt = chrono::DateTime::from_timestamp(secs, 0)?;
365 Some(dt.to_rfc3339())
366 }
367 _ => None,
368 }
369}
370
371fn parse_array_or_string(value: &Value) -> Vec<String> {
373 match value {
374 Value::Array(arr) => arr
375 .iter()
376 .filter_map(|v| v.as_str().map(|s| s.to_string()))
377 .filter(|s| !s.is_empty())
378 .collect(),
379 Value::String(s) => s
380 .lines()
381 .map(|line| {
382 let trimmed = line.trim();
383 let stripped = trimmed
385 .trim_start_matches('-')
386 .trim_start_matches('*')
387 .trim()
388 .trim_start_matches("## ")
389 .trim();
390 let stripped = stripped.strip_prefix("**").unwrap_or(stripped);
392 let stripped = stripped.strip_suffix("**").unwrap_or(stripped);
393 stripped.trim().to_string()
394 })
395 .filter(|s| !s.is_empty())
396 .collect(),
397 _ => vec![],
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404
405 fn key(s: &str) -> SecretString {
406 SecretString::from(s.to_string())
407 }
408
409 #[test]
410 fn test_parse_array_or_string_with_array() {
411 let val = json!(["item1", "item2", "item3"]);
412 let result = parse_array_or_string(&val);
413 assert_eq!(result, vec!["item1", "item2", "item3"]);
414 }
415
416 #[test]
417 fn test_parse_array_or_string_with_markdown() {
418 let val = json!("**Actions**\n- Review PR\n- Update docs\n");
419 let result = parse_array_or_string(&val);
420 assert_eq!(result, vec!["Actions", "Review PR", "Update docs"]);
421 }
422
423 #[test]
424 fn test_parse_array_or_string_with_null() {
425 let val = json!(null);
426 let result = parse_array_or_string(&val);
427 assert!(result.is_empty());
428 }
429
430 #[test]
431 fn test_convert_transcript_duration_conversion() {
432 let t = FirefliesTranscript {
433 id: "test-id".into(),
434 title: Some("Test Meeting".into()),
435 date: Some(Value::String("2025-01-15T10:00:00Z".into())),
436 duration: Some(45.5), host_email: None,
438 organizer_email: None,
439 meeting_attendees: None,
440 speakers: None,
441 transcript_url: None,
442 audio_url: None,
443 video_url: None,
444 meeting_link: None,
445 summary: None,
446 sentences: None,
447 };
448
449 let note = convert_transcript(t);
450 assert_eq!(note.duration_seconds, Some(2730)); }
452
453 #[test]
454 fn test_convert_transcript_with_attendees_and_speakers() {
455 let t = FirefliesTranscript {
456 id: "m-123".into(),
457 title: Some("Sprint Planning".into()),
458 date: None,
459 duration: None,
460 host_email: Some("host@example.com".into()),
461 organizer_email: Some("org@example.com".into()),
462 meeting_attendees: Some(vec![
463 FirefliesAttendee {
464 display_name: None,
465 email: Some("alice@example.com".into()),
466 name: None,
467 },
468 FirefliesAttendee {
469 display_name: Some("Bob".into()),
470 email: None,
471 name: None,
472 },
473 ]),
474 speakers: Some(vec![
475 FirefliesSpeaker {
476 id: Some("1".into()),
477 name: Some("Alice".into()),
478 },
479 FirefliesSpeaker {
480 id: Some("2".into()),
481 name: Some("Bob".into()),
482 },
483 ]),
484 transcript_url: None,
485 audio_url: None,
486 video_url: None,
487 meeting_link: None,
488 summary: Some(FirefliesSummary {
489 keywords: Some(json!(["rust", "migration"])),
490 action_items: Some(json!("- Review PR\n- Update docs")),
491 topics_discussed: None,
492 meeting_type: Some("planning".into()),
493 overview: Some("Team discussed migration plan.".into()),
494 short_summary: None,
495 }),
496 sentences: None,
497 };
498
499 let note = convert_transcript(t);
500 assert_eq!(note.id, "m-123");
501 assert_eq!(note.title, "Sprint Planning");
502 assert_eq!(note.host_email, Some("host@example.com".into()));
503 assert_eq!(
504 note.participants,
505 vec!["alice@example.com".to_string(), "Bob".to_string()]
506 );
507 assert_eq!(note.speakers.len(), 2);
508 assert_eq!(note.speakers[0].name, "Alice");
509 assert_eq!(note.keywords, vec!["rust", "migration"]);
510 assert_eq!(note.action_items, vec!["Review PR", "Update docs"]);
511 assert_eq!(note.meeting_type, Some("planning".into()));
512 assert_eq!(note.summary, Some("Team discussed migration plan.".into()));
513 }
514
515 #[test]
516 fn test_convert_transcript_no_duration() {
517 let t = FirefliesTranscript {
518 id: "no-dur".into(),
519 title: None,
520 date: None,
521 duration: None,
522 host_email: None,
523 organizer_email: None,
524 meeting_attendees: None,
525 speakers: None,
526 transcript_url: None,
527 audio_url: None,
528 video_url: None,
529 meeting_link: None,
530 summary: None,
531 sentences: None,
532 };
533
534 let note = convert_transcript(t);
535 assert_eq!(note.duration_seconds, None);
536 assert!(note.title.is_empty());
537 }
538
539 #[test]
540 fn test_parse_array_or_string_with_empty_array() {
541 let val = json!([]);
542 let result = parse_array_or_string(&val);
543 assert!(result.is_empty());
544 }
545
546 #[test]
547 fn test_parse_array_or_string_filters_empty_strings() {
548 let val = json!(["item1", "", "item2"]);
549 let result = parse_array_or_string(&val);
550 assert_eq!(result, vec!["item1", "item2"]);
551 }
552
553 #[test]
554 fn test_build_filter_variables_empty() {
555 let filter = MeetingFilter::default();
556 let vars = build_filter_variables(&filter);
557 let obj = vars.as_object().unwrap();
558 assert_eq!(obj.get("limit").unwrap(), &json!(50));
559 assert!(obj.get("keyword").is_none());
560 assert!(obj.get("fromDate").is_none());
561 }
562
563 #[test]
564 fn test_build_filter_variables_full() {
565 let filter = MeetingFilter {
566 keyword: Some("sprint".into()),
567 from_date: Some("2025-01-01".into()),
568 to_date: Some("2025-12-31".into()),
569 participants: Some(vec!["alice@ex.com".into()]),
570 host_email: Some("host@ex.com".into()),
571 limit: Some(10),
572 skip: Some(5),
573 };
574 let vars = build_filter_variables(&filter);
575 let obj = vars.as_object().unwrap();
576 assert_eq!(obj["keyword"], json!("sprint"));
577 assert_eq!(obj["fromDate"], json!("2025-01-01"));
578 assert_eq!(obj["toDate"], json!("2025-12-31"));
579 assert_eq!(obj["host_email"], json!("host@ex.com"));
580 assert_eq!(obj["participants"], json!(["alice@ex.com"]));
581 assert_eq!(obj["limit"], json!(10));
582 assert_eq!(obj["skip"], json!(5));
583 }
584
585 #[test]
586 fn test_fireflies_client_new() {
587 let client = FirefliesClient::new(key("test-key"));
588 assert_eq!(client.provider_name(), "fireflies");
589 }
590
591 #[test]
592 fn test_with_api_url_overrides_endpoint() {
593 let client = FirefliesClient::new(key("k")).with_api_url("http://localhost:1234/gql");
594 assert_eq!(client.api_url, "http://localhost:1234/gql");
595 }
596
597 mod integration {
602 use super::*;
603 use httpmock::prelude::*;
604
605 fn key(s: &str) -> SecretString {
606 SecretString::from(s.to_string())
607 }
608
609 fn mock_client(server: &MockServer) -> FirefliesClient {
610 FirefliesClient::new(key("test-token")).with_api_url(server.url("/gql"))
611 }
612
613 #[tokio::test]
614 async fn get_meetings_returns_parsed_transcripts() {
615 let server = MockServer::start();
616 server.mock(|when, then| {
617 when.method(POST)
618 .path("/gql")
619 .header("authorization", "Bearer test-token");
620 then.status(200).json_body(json!({
621 "data": {
622 "transcripts": [
623 {
624 "id": "t1",
625 "title": "Sprint planning",
626 "date": "2025-04-15T10:00:00Z",
627 "duration": 45,
628 "host_email": "host@ex.com",
629 "organizer_email": null,
630 "meeting_attendees": [
631 { "displayName": "Alice", "email": "alice@ex.com", "name": "Alice" }
632 ],
633 "speakers": [{ "id": "1", "name": "Alice" }],
634 "transcript_url": null,
635 "audio_url": null,
636 "video_url": null,
637 "meeting_link": null,
638 "summary": {
639 "keywords": [],
640 "action_items": null,
641 "topics_discussed": [],
642 "meeting_type": null,
643 "overview": null,
644 "short_summary": null
645 }
646 }
647 ]
648 }
649 }));
650 });
651
652 let client = mock_client(&server);
653 let result = client.get_meetings(MeetingFilter::default()).await.unwrap();
654 assert_eq!(result.items.len(), 1);
655 assert_eq!(result.items[0].title, "Sprint planning");
656 }
657
658 #[tokio::test]
659 async fn get_transcript_maps_sentences_to_speaker_names() {
660 let server = MockServer::start();
661 server.mock(|when, then| {
662 when.method(POST).path("/gql");
663 then.status(200).json_body(json!({
664 "data": {
665 "transcript": {
666 "id": "t1",
667 "title": "Sprint",
668 "date": "2025-04-15T10:00:00Z",
669 "duration": 45,
670 "speakers": [
671 { "id": "1", "name": "Alice" },
672 { "id": 2, "name": "Bob" }
673 ],
674 "sentences": [
675 { "speaker_id": "1", "text": "Hi", "start_time": 0.0, "end_time": 1.0 },
676 { "speaker_id": 2, "text": "Hey", "start_time": 1.5, "end_time": 2.5 },
677 { "speaker_id": null, "text": "—", "start_time": 3.0, "end_time": 3.2 }
678 ]
679 }
680 }
681 }));
682 });
683
684 let client = mock_client(&server);
685 let t = client.get_transcript("t1").await.unwrap();
686 assert_eq!(t.meeting_id, "t1");
687 assert_eq!(t.sentences.len(), 3);
688 assert_eq!(t.sentences[0].speaker_name.as_deref(), Some("Alice"));
690 assert_eq!(t.sentences[1].speaker_name.as_deref(), Some("Bob"));
691 assert!(t.sentences[2].speaker_name.is_none());
693 assert_eq!(t.sentences[2].speaker_id, "");
694 }
695
696 #[tokio::test]
697 async fn get_transcript_missing_returns_not_found() {
698 let server = MockServer::start();
699 server.mock(|when, then| {
700 when.method(POST).path("/gql");
701 then.status(200)
702 .json_body(json!({ "data": { "transcript": null } }));
703 });
704 let client = mock_client(&server);
705 let err = client.get_transcript("missing").await.unwrap_err();
706 assert!(matches!(err, Error::NotFound(_)), "got {err:?}");
707 }
708
709 #[tokio::test]
710 async fn graphql_401_becomes_unauthorized_error() {
711 let server = MockServer::start();
712 server.mock(|when, then| {
713 when.method(POST).path("/gql");
714 then.status(401).body("nope");
715 });
716 let client = mock_client(&server);
717 let err = client
718 .get_meetings(MeetingFilter::default())
719 .await
720 .unwrap_err();
721 assert!(matches!(err, Error::Unauthorized(_)), "got {err:?}");
722 }
723
724 #[tokio::test]
725 async fn graphql_500_becomes_api_error_with_status() {
726 let server = MockServer::start();
727 server.mock(|when, then| {
728 when.method(POST).path("/gql");
729 then.status(500).body("boom");
730 });
731 let client = mock_client(&server);
732 let err = client.get_transcript("anything").await.unwrap_err();
733 match err {
734 Error::Api { status, message } => {
735 assert_eq!(status, 500);
736 assert!(message.contains("boom"));
737 }
738 other => panic!("expected Api, got {other:?}"),
739 }
740 }
741
742 #[tokio::test]
743 async fn graphql_errors_field_surfaces_as_api_error() {
744 let server = MockServer::start();
745 server.mock(|when, then| {
746 when.method(POST).path("/gql");
747 then.status(200).json_body(json!({
748 "errors": [
749 {"message": "rate limited"},
750 {"message": "again"}
751 ]
752 }));
753 });
754 let client = mock_client(&server);
755 let err = client
756 .get_meetings(MeetingFilter::default())
757 .await
758 .unwrap_err();
759 match err {
760 Error::Api { status, message } => {
761 assert_eq!(status, 200);
762 assert!(message.contains("rate limited"));
763 assert!(message.contains("again"));
764 }
765 other => panic!("expected Api, got {other:?}"),
766 }
767 }
768
769 #[tokio::test]
770 async fn graphql_missing_data_is_invalid_data_error() {
771 let server = MockServer::start();
772 server.mock(|when, then| {
773 when.method(POST).path("/gql");
774 then.status(200).json_body(json!({}));
777 });
778 let client = mock_client(&server);
779 let err = client
780 .get_meetings(MeetingFilter::default())
781 .await
782 .unwrap_err();
783 assert!(matches!(err, Error::InvalidData(_)), "got {err:?}");
784 }
785
786 #[tokio::test]
787 async fn search_meetings_adds_keyword_and_hits_endpoint() {
788 let server = MockServer::start();
789 server.mock(|when, then| {
790 when.method(POST)
791 .path("/gql")
792 .body_includes("\"keyword\":\"rollback\"");
793 then.status(200).json_body(json!({
794 "data": { "transcripts": [] }
795 }));
796 });
797 let client = mock_client(&server);
798 let result = client
799 .search_meetings("rollback", MeetingFilter::default())
800 .await
801 .unwrap();
802 assert_eq!(result.items.len(), 0);
803 }
804 }
805}