Skip to content

Commit a1dd453

Browse files
committed
feat(rwi): carry agent_id/agent_name in recording-end webhook events
Add CallMetaStore::update_agent so the CC session hook can publish the resolved agent_id/agent_name on call connected. The gateway enrichment path (enrich_flat_event) then injects these into all owner-scoped events, including record_end and recording_metadata_available (whose struct had the fields but were hardcoded to None). - proto: CallMetaStore::update_agent + unit tests - gateway: end-to-end enrichment tests - recording_upload/sipflow_upload: fill RecordingMetadata agent fields from the meta store instead of None - bump submodules: cc (agent publishing), telemetry (Drop impl), ivr_editor (latest)
1 parent 81a4d5e commit a1dd453

7 files changed

Lines changed: 201 additions & 7 deletions

File tree

src/addons/cc

Submodule cc updated from 4436e81 to 421a325

src/addons/ivr_editor

Submodule ivr_editor updated from e49d615 to 9f45b77

src/addons/telemetry

Submodule telemetry updated from 35959f0 to a03daeb

src/callrecord/recording_upload.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -251,6 +251,14 @@ impl CallRecordHook for RecordingUploadHook {
251251
// (e.g. MQ consumers) are notified that the recording is ready.
252252
if let Some(ref gw) = self.rwi_gateway {
253253
use crate::rwi::proto::RecordingMetadata;
254+
// Pull agent context from the RWI meta store (populated on call
255+
// connected); fall back to CallRecord.details for agent_name.
256+
let (meta_agent_id, meta_agent_name) = gw
257+
.read()
258+
.meta_store
259+
.get_sync(&record.call_id)
260+
.map(|m| (m.agent_id, m.agent_name))
261+
.unwrap_or((None, None));
254262
let metadata = RecordingMetadata {
255263
filename: record
256264
.recorder
@@ -268,8 +276,8 @@ impl CallRecordHook for RecordingUploadHook {
268276
callee_name: extract_sip_username(&record.callee),
269277
called_phone: extract_sip_username(&record.callee),
270278
call_type: record.details.direction.clone(),
271-
agent_id: None,
272-
agent_name: None,
279+
agent_id: meta_agent_id,
280+
agent_name: meta_agent_name.or_else(|| record.details.agent_name.clone()),
273281
call_start_time: Some(record.start_time.to_rfc3339()),
274282
call_end_time: Some(record.end_time.to_rfc3339()),
275283
upload_time: Some(chrono::Utc::now().to_rfc3339()),

src/callrecord/sipflow_upload.rs

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,13 @@ async fn do_upload(
205205
if let Some(url) = first_uploaded_url.as_ref() {
206206
if let Some(gw) = rwi_gateway {
207207
use crate::rwi::proto::RecordingMetadata;
208+
// Pull agent context from the RWI meta store when available.
209+
let (meta_agent_id, meta_agent_name) = gw
210+
.read()
211+
.meta_store
212+
.get_sync(call_id)
213+
.map(|m| (m.agent_id, m.agent_name))
214+
.unwrap_or((None, None));
208215
let metadata = RecordingMetadata {
209216
filename: media_key.to_string(),
210217
unique_id: call_id.to_string(),
@@ -214,8 +221,8 @@ async fn do_upload(
214221
callee_name: None,
215222
called_phone: None,
216223
call_type: "".to_string(),
217-
agent_id: None,
218-
agent_name: None,
224+
agent_id: meta_agent_id,
225+
agent_name: meta_agent_name,
219226
call_start_time: Some(start.to_rfc3339()),
220227
call_end_time: Some(end.to_rfc3339()),
221228
upload_time: Some(Utc::now().to_rfc3339()),

src/rwi/gateway.rs

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -797,4 +797,69 @@ mod tests {
797797
let v = rx.recv().await.unwrap();
798798
assert!(v.to_string().contains("call_answered"));
799799
}
800+
801+
/// `send_to_owner` must enrich the event payload with `agent_id` /
802+
/// `agent_name` pulled from the [`CallMetaStore`] (populated on call
803+
/// connected). This is the path that carries agent context into the
804+
/// `record_end` / `recording_metadata_available` webhook events even
805+
/// though those event structs have no agent fields of their own.
806+
#[tokio::test]
807+
async fn test_send_to_owner_enriches_agent_from_meta_store() {
808+
let mut gw = RwiGateway::new();
809+
let (tx, mut rx) = broadcast::channel::<EventCacheEntry>(16);
810+
gw.set_webhook_tx(tx);
811+
812+
// Populate agent context as CcCallSessionHook does on call connected.
813+
gw.meta_store
814+
.update_agent(
815+
"call-1",
816+
Some("agent-42".to_string()),
817+
Some("Alice".to_string()),
818+
)
819+
.await;
820+
821+
// RecordEnd has NO agent_id / agent_name fields on the struct itself.
822+
gw.send_to_owner(&crate::rwi::RecordEnd {
823+
call_id: "call-1".to_string(),
824+
url: Some("https://example.com/rec.wav".to_string()),
825+
duration_secs: 12,
826+
file_size: 1024,
827+
});
828+
829+
let entry = rx.recv().await.expect("webhook must receive record_end");
830+
assert_eq!(entry.event.event_type, "record_end");
831+
// Agent context injected by enrich_flat_event.
832+
assert_eq!(entry.event.payload["agent_id"].as_str(), Some("agent-42"));
833+
assert_eq!(entry.event.payload["agent_name"].as_str(), Some("Alice"));
834+
// Original RecordEnd fields are preserved.
835+
assert_eq!(
836+
entry.event.payload["url"].as_str(),
837+
Some("https://example.com/rec.wav")
838+
);
839+
assert_eq!(entry.event.payload["duration_secs"].as_u64(), Some(12));
840+
}
841+
842+
/// When no agent context exists for a call, enrichment must leave the
843+
/// payload untouched (no spurious null agent fields).
844+
#[tokio::test]
845+
async fn test_send_to_owner_no_enrichment_without_meta() {
846+
let mut gw = RwiGateway::new();
847+
let (tx, mut rx) = broadcast::channel::<EventCacheEntry>(16);
848+
gw.set_webhook_tx(tx);
849+
850+
gw.send_to_owner(&crate::rwi::RecordEnd {
851+
call_id: "call-unknown".to_string(),
852+
url: None,
853+
duration_secs: 0,
854+
file_size: 0,
855+
});
856+
857+
let entry = rx.recv().await.unwrap();
858+
assert_eq!(entry.event.event_type, "record_end");
859+
assert!(
860+
entry.event.payload.get("agent_id").is_none(),
861+
"no agent fields when meta absent, got: {}",
862+
entry.event.payload
863+
);
864+
}
800865
}

src/rwi/proto.rs

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,27 @@ impl CallMetaStore {
120120
pub async fn remove(&self, call_id: &str) {
121121
self.store.write().await.remove(call_id);
122122
}
123+
124+
/// Update the agent fields on an existing call's metadata.
125+
///
126+
/// Called when a call is assigned to an agent (e.g. on call connected) so
127+
/// that subsequently emitted events (record_end, recording_metadata_available,
128+
/// call_hangup, …) are enriched with `agent_id`/`agent_name` via
129+
/// [`crate::rwi::gateway::RwiGateway::enrich_flat_event`].
130+
///
131+
/// If no entry exists for `call_id` yet (rare race with session creation),
132+
/// a default [`CallMeta`] is inserted carrying only the agent fields.
133+
pub async fn update_agent(
134+
&self,
135+
call_id: &str,
136+
agent_id: Option<String>,
137+
agent_name: Option<String>,
138+
) {
139+
let mut store = self.store.write().await;
140+
let meta = store.entry(call_id.to_string()).or_default();
141+
meta.agent_id = agent_id;
142+
meta.agent_name = agent_name;
143+
}
123144
}
124145

125146
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -142,3 +163,96 @@ pub struct RecordingMetadata {
142163
pub process_flag: Option<String>,
143164
pub root_call_id: Option<String>,
144165
}
166+
167+
#[cfg(test)]
168+
mod tests {
169+
use super::*;
170+
171+
#[tokio::test]
172+
async fn update_agent_sets_fields_on_existing_entry() {
173+
let store = CallMetaStore::new();
174+
store
175+
.insert(
176+
"call-1".to_string(),
177+
CallMeta {
178+
caller: Some("1001".to_string()),
179+
callee: Some("1002".to_string()),
180+
caller_name: Some("alice".to_string()),
181+
..Default::default()
182+
},
183+
)
184+
.await;
185+
186+
store
187+
.update_agent(
188+
"call-1",
189+
Some("agent-007".to_string()),
190+
Some("James Bond".to_string()),
191+
)
192+
.await;
193+
194+
let meta = store.get("call-1").await.expect("meta must exist");
195+
assert_eq!(meta.agent_id.as_deref(), Some("agent-007"));
196+
assert_eq!(meta.agent_name.as_deref(), Some("James Bond"));
197+
// Existing fields must be preserved.
198+
assert_eq!(meta.caller.as_deref(), Some("1001"));
199+
assert_eq!(meta.caller_name.as_deref(), Some("alice"));
200+
}
201+
202+
#[tokio::test]
203+
async fn update_agent_creates_default_entry_when_absent() {
204+
let store = CallMetaStore::new();
205+
// No prior insert for "call-2".
206+
store
207+
.update_agent("call-2", Some("agent-x".to_string()), None)
208+
.await;
209+
210+
let meta = store.get("call-2").await.expect("meta created on the fly");
211+
assert_eq!(meta.agent_id.as_deref(), Some("agent-x"));
212+
assert!(meta.agent_name.is_none());
213+
}
214+
215+
#[tokio::test]
216+
async fn update_agent_clears_fields_when_none() {
217+
let store = CallMetaStore::new();
218+
store
219+
.insert(
220+
"call-3".to_string(),
221+
CallMeta {
222+
agent_id: Some("old".to_string()),
223+
agent_name: Some("old name".to_string()),
224+
..Default::default()
225+
},
226+
)
227+
.await;
228+
229+
store.update_agent("call-3", None, None).await;
230+
231+
let meta = store.get("call-3").await.unwrap();
232+
assert!(meta.agent_id.is_none());
233+
assert!(meta.agent_name.is_none());
234+
}
235+
236+
#[tokio::test]
237+
async fn update_agent_then_eventcallcontext_from_carries_fields() {
238+
let store = CallMetaStore::new();
239+
store
240+
.insert(
241+
"call-4".to_string(),
242+
CallMeta {
243+
caller: Some("2001".to_string()),
244+
..Default::default()
245+
},
246+
)
247+
.await;
248+
store
249+
.update_agent("call-4", Some("agent-9".to_string()), Some("Nine".to_string()))
250+
.await;
251+
252+
let meta = store.get("call-4").await.unwrap();
253+
let ctx = EventCallContext::from(meta);
254+
assert_eq!(ctx.agent_id.as_deref(), Some("agent-9"));
255+
assert_eq!(ctx.agent_name.as_deref(), Some("Nine"));
256+
assert_eq!(ctx.caller.as_deref(), Some("2001"));
257+
}
258+
}

0 commit comments

Comments
 (0)