Skip to content

Conversation

akulakum
Copy link
Contributor

@akulakum akulakum commented Aug 19, 2025

COMPLETES #https://jira-eng-sjc12.cisco.com/jira/browse/CAI-6767

Vidcast link: https://app.vidcast.io/share/6a0c6741-f865-460f-9b47-5bf960dc1853

This pull request addresses

Implemented the IVR transcript feature in the CC SDK.

To improve customer experience, Virtual Agents handle most initial queries. When escalation to a human agent is required, the full transcript of the customer’s interaction with the Virtual Agent (the IVR transcript) is shared with the agent. This prevents customers from having to repeat themselves and ensures a smooth handoff.

Added support for retrieving IVR transcripts via two REST API calls:

  1. Fetch transcript URL – Retrieves a time-bound signed URL (transcriptPath) from the Media Storage service (AWS S3).
  2. Fetch transcript content – Uses the signed URL to retrieve the actual transcript data.

by making the following changes

Created a new service file called IvrTranscriptService, where we make API requests to retrieve the IVR transcript response.

We then instantiate this service in the Services class.

Through the Services class, we access the fetchIvrTranscript method from the IvrTranscriptService in the task component, making it part of the currentTask.

Agent Desktop Code References: https://sqbu-github.cisco.com/CBABU/wxcc-desktop/blob/2635b235b0fdb78c4174b483f63cba62947cbc5e/packages/agentx-mfe-wc-based/src/components/IvrTranscripts/IvrTranscripts.ts#L76

https://sqbu-github.cisco.com/CBABU/wxcc-desktop/blob/2635b235b0fdb78c4174b483f63cba62947cbc5e/packages/agentx-services/src/services/mediaStorage/media-storage-service.ts#L74

Metrics
Screenshot 2025-09-15 at 5 14 16 PM

Upload logs
Screenshot 2025-09-15 at 5 14 44 PM

Change Type

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Tooling change
  • Internal code refactor

The following scenarios were tested

< ENUMERATE TESTS PERFORMED, WHETHER MANUAL OR AUTOMATED >

The GAI Coding Policy And Copyright Annotation Best Practices

  • GAI was not used (or, no additional notation is required)
  • Code was generated entirely by GAI
  • GAI was used to create a draft that was subsequently customized or modified
  • Coder created a draft manually that was non-substantively modified by GAI (e.g., refactoring was performed by GAI on manually written code)
  • Tool used for AI assistance (GitHub Copilot / Other - specify)
    • Github Copilot
    • Other - Please Specify
  • This PR is related to
    • Feature
    • Defect fix
    • Tech Debt
    • Automation

I certified that

  • I have read and followed contributing guidelines
  • I discussed changes with code owners prior to submitting this pull request
  • I have not skipped any automated checks
  • All existing and new tests passed
  • I have updated the documentation accordingly

Make sure to have followed the contributing guidelines before submitting.

@akulakum akulakum requested a review from a team as a code owner August 19, 2025 14:02
Copy link
Contributor

coderabbitai bot commented Aug 19, 2025

📝 Walkthrough

Walkthrough

Adds IVR transcript support across the contact-center sample and plugin-cc Task service. The sample UI (HTML/JS) introduces controls to fetch and display IVR transcripts, with state management and rendering logic. The Task service exposes a new fetchIvrTranscript(orgId, interactionId, timeoutMins) API, backed by a new IvrTranscriptService that retrieves transcript metadata and conversations via Webex requests. New IVR transcript types are defined. Metrics constants gain success/failure events, and Task records corresponding metrics. Unit tests cover success, error, empty, and multi-transcript cases, including service mocking and metrics assertions.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Tip

🔌 Remote MCP (Model Context Protocol) integration is now available!

Pro plan users can now connect to remote MCP servers from the Integrations page. Connect with popular remote MCPs such as Notion and Linear to add more context to your reviews and chats.

✨ Finishing Touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share
🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.

Support

Need help? Create a ticket on our support page for assistance with any issues or questions.

CodeRabbit Commands (Invoked using PR/Issue comments)

Type @coderabbitai help to get the list of available commands.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

Status, Documentation and Community

  • Visit our Status Page to check the current availability of CodeRabbit.
  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

Copy link

This pull request is automatically being deployed by Amplify Hosting (learn more).

Access this pull request here: https://pr-4448.d3m3l2kee0btzx.amplifyapp.com

@rsarika rsarika added the validated If the pull request is validated for automation. label Aug 19, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

🧹 Nitpick comments (11)
packages/@webex/plugin-cc/src/services/task/types.ts (1)

970-1069: IVR transcript type model looks sound; minor typing nit

Overall structure is clear and matches the service shape. Consider avoiding any for parameters to improve type safety.

Apply this small tightening:

-  /** Parameters extracted from the conversation */
-  parameters?: Record<string, any>;
+  /** Parameters extracted from the conversation */
+  parameters?: Record<string, unknown>;
docs/samples/contact-center/index.html (1)

248-261: Minor accessibility improvements for IVR Transcript section

  • Make the action button explicit type to avoid accidental form submits.
  • Add an accessible label for the timeout input.
  • Announce status updates to assistive tech.
-                    <button id="fetch-ivr-transcript" class="btn--blue" disabled>Fetch IVR Transcript</button>
-                    <input id="timeout-mins" placeholder="Timeout (mins)" value="5" type="number" min="1" max="60" style="width: 120px; margin-left: 10px;">
+                    <button id="fetch-ivr-transcript" type="button" class="btn--blue" disabled>Fetch IVR Transcript</button>
+                    <input id="timeout-mins" aria-label="Timeout in minutes" placeholder="Timeout (mins)" value="5" type="number" min="1" max="60" style="width: 120px; margin-left: 10px;">
...
-                    <p id="ivr-transcript-status" class="status-par">No transcript fetched</p>
+                    <p id="ivr-transcript-status" class="status-par" aria-live="polite">No transcript fetched</p>
packages/@webex/plugin-cc/src/services/task/index.ts (2)

169-190: Avoid any cast to access webex on WebexRequest

Accessing (webexRequest as any).webex is fragile and bypasses type safety. Prefer exposing a typed accessor (e.g., WebexRequest.getInstance().getWebex()), or typing the instance to a known interface.

If you want, I can propose a small change in WebexRequest to expose a getWebex() method and update this call site accordingly.


169-190: Parameterize service base URL in IvrTranscriptService (follow-up)

The service currently hardcodes the mediastorage base URL and region (see IvrTranscriptService.getIvrTranscriptMetadata in the related file). Consider sourcing this from configuration/service-catalog to support multi-region and non-prod environments.

Would you like a quick proposal for config-driven base URL selection for the IVR transcript service?

packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts (3)

26-26: Unused import of METRICS_CONSTANTS

It looks unused in this file; you’re already using METRIC_EVENT_NAMES. Consider removing to avoid lint warnings.

-import * as METRICS_CONSTANTS from '../../../../../src/metrics/constants';

1491-1498: Unnecessary global.fetch mock

The IVR flow uses webex.request through IvrTranscriptService, not global fetch. This mock isn’t used and can be removed.

-    // Mock global fetch
-    global.fetch = jest.fn();

1609-1643: Test name vs behavior mismatch

You’re asserting a success path that returns [] but the test title says “handle error when fetching transcript content from S3”. The behavior is fine (service continues on per-transcript error and returns aggregated results), but the title should reflect it returns an empty array and records success.

Consider renaming the test to: “should return empty array and still record success metric when transcript content fetch yields no data”.

docs/samples/contact-center/app.js (2)

1845-1860: Duplicate state refresh calls

handleTaskSelect calls updateIvrTranscriptButtonState twice; one call is sufficient.

   currentTask = task
-  console.log('Updating IVR button state for selected task...');
-  updateIvrTranscriptButtonState(); // Update IVR button state when task is selected
@@
   updateCallControlUI(task); // Enable/disable transfer controls
-  updateIvrTranscriptButtonState(); // Update IVR button state when task is selected
+  updateIvrTranscriptButtonState(); // Update IVR button state when task is selected

1243-1249: Event wiring is fine; consider null-guard

If the sample HTML is edited, fetchIvrTranscriptBtn could be null. A defensive guard would avoid a runtime error.

-console.log('Setting up IVR transcript fetch button event listener...');
-fetchIvrTranscriptBtn.addEventListener('click', fetchIvrTranscript);
+console.log('Setting up IVR transcript fetch button event listener...');
+if (fetchIvrTranscriptBtn) {
+  fetchIvrTranscriptBtn.addEventListener('click', fetchIvrTranscript);
+}
packages/@webex/plugin-cc/src/services/task/IvrTranscriptService.ts (2)

55-55: Hard-coded media storage base URL

Embedding region-specific hostnames (produs1) can cause breakage in other regions/environments. Prefer deriving this from the Webex SDK service catalog/config, or a configurable base.

I can propose a small helper to read from webex.config.services if available and fall back to a default. Want me to draft that?


256-285: Sequential per-transcript fetch can be slow for many transcripts

You’re matching “agent desktop logic,” but if latency becomes an issue, consider parallelizing with Promise.allSettled and preserving order when concatenating.

Here’s a sketch while preserving per-item error tolerance:

const results = await Promise.allSettled(
  transcriptMetaDataList.map((t) =>
    this.fetchIvrConversation(t.transcriptPath).then((conv) => this.parseConversations(conv, t.botName))
  )
);
let transcriptConversations: IvrTranscriptResponse = [];
results.forEach((r, i) => {
  if (r.status === 'fulfilled') transcriptConversations = transcriptConversations.concat(r.value);
  else LoggerProxy.warn(`Failed to process transcript ${transcriptMetaDataList[i].transcriptId}: ${r.reason}`, { module: 'IvrTranscriptService', method: 'fetchIVRTranscript', interactionId });
});
📜 Review details

Configuration used: .coderabbit.yaml
Review profile: CHILL
Plan: Pro

💡 Knowledge Base configuration:

  • MCP integration is disabled by default for public repositories
  • Jira integration is disabled by default for public repositories
  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 948a9d8 and 95e1613.

📒 Files selected for processing (8)
  • docs/samples/contact-center/app.js (7 hunks)
  • docs/samples/contact-center/index.html (1 hunks)
  • packages/@webex/plugin-cc/src/metrics/constants.ts (2 hunks)
  • packages/@webex/plugin-cc/src/services/task/IvrTranscriptService.ts (1 hunks)
  • packages/@webex/plugin-cc/src/services/task/constants.ts (1 hunks)
  • packages/@webex/plugin-cc/src/services/task/index.ts (5 hunks)
  • packages/@webex/plugin-cc/src/services/task/types.ts (2 hunks)
  • packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts (7 hunks)
🧰 Additional context used
🧬 Code Graph Analysis (5)
docs/samples/contact-center/app.js (2)
packages/@webex/plugin-cc/src/services/task/index.ts (1)
  • fetchIvrTranscript (1081-1159)
packages/@webex/plugin-cc/src/logger-proxy.ts (1)
  • error (85-89)
packages/@webex/plugin-cc/src/services/task/index.ts (4)
packages/@webex/plugin-cc/src/services/task/IvrTranscriptService.ts (1)
  • IvrTranscriptService (10-306)
packages/@webex/plugin-cc/src/services/task/types.ts (1)
  • IvrTranscriptResponse (1068-1068)
packages/@webex/plugin-cc/src/metrics/MetricsManager.ts (1)
  • MetricsManager (38-512)
packages/@webex/plugin-cc/src/services/core/Utils.ts (1)
  • getErrorDetails (77-116)
packages/@webex/plugin-cc/src/services/task/types.ts (1)
docs/labs/contact-center/task-manager.js (1)
  • interactionId (288-288)
packages/@webex/plugin-cc/src/services/task/IvrTranscriptService.ts (4)
docs/samples/contact-center/app.js (2)
  • webex (2-2)
  • transcript (662-662)
docs/labs/contact-center/task-manager.js (1)
  • interactionId (288-288)
packages/@webex/plugin-cc/src/services/task/types.ts (3)
  • IvrTranscriptMetaDataResponse (993-1002)
  • IvrTranscriptResponse (1068-1068)
  • IvrTranscriptData (1059-1062)
packages/@webex/plugin-cc/src/logger-proxy.ts (1)
  • LoggerProxy (9-110)
packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts (3)
packages/@webex/plugin-cc/src/services/task/index.ts (1)
  • Task (132-1522)
packages/@webex/plugin-cc/src/services/task/types.ts (2)
  • MEDIA_CHANNEL (69-84)
  • MEDIA_CHANNEL (91-91)
packages/@webex/plugin-cc/src/metrics/constants.ts (2)
  • METRIC_EVENT_NAMES (70-129)
  • METRIC_EVENT_NAMES (142-142)
🔇 Additional comments (18)
packages/@webex/plugin-cc/src/services/task/constants.ts (1)

35-35: LGTM: New Task method identifier added correctly

FETCH_IVR_TRANSCRIPT aligns with the new Task API and matches usage in Task.fetchIvrTranscript.

packages/@webex/plugin-cc/src/metrics/constants.ts (2)

54-56: JSDoc extended for IVR transcript metrics — looks good

The added documentation entries match the new constants and follow existing style.


115-118: LGTM: New IVR transcript metric names added

Names are consistent with existing naming conventions and are used by Task.fetchIvrTranscript.

packages/@webex/plugin-cc/src/services/task/index.ts (1)

9-11: Imports and type surface extension look good

IvrTranscriptService, WebexRequest, and IvrTranscriptResponse are wired correctly for the new API.

Also applies to: 27-27

packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts (8)

10-10: Good addition: test harness now imports the new IvrTranscriptService

This aligns the tests with the new Task.fetchIvrTranscript path.


21-21: MEDIA_CHANNEL import is appropriate

Consistent with the type surface used in IVR-related tests.


32-40: Solid mocking of IvrTranscriptService

Mocking the default export to a simple object with fetchIVRTranscript keeps test setup clean and focused.


104-110: Good: WebexRequest mock includes webex

Task.getIvrTranscriptService validates WebexRequest.getInstance().webex; exposing it on the mock avoids false negatives.


159-159: Constructor usage updated

Passing { wrapUpProps: null } matches the new Task constructor signature and avoids unintended AutoWrapup initialization.


1500-1562: Happy path IVR test looks good

Overriding getIvrTranscriptService() and asserting conversationTurns + metrics matches the implementation.


1564-1583: Validation test for missing orgId is appropriate

Covers the Task-side guard and failure metric.


1679-1738: Happy path for multiple transcripts looks correct

Overrides service, validates output content and success metric with conversationTurns: 4.

docs/samples/contact-center/app.js (3)

82-91: UI element wiring is fine

Initialization logging helps diagnose missing DOM nodes in the sample app.


744-761: IVR button state refresh on task:end is good

Keeps UI controls consistent when the task completes.


1478-1487: Enable IVR button post-accept

This hook is in the right place; aligns with the “accepted + telephony” gating.

packages/@webex/plugin-cc/src/services/task/IvrTranscriptService.ts (3)

99-139: Conversation fetch logic is reasonable

Graceful parsing handles both {conversation: [...] } and raw arrays; logs and errors are appropriate.


152-176: Parameter flattening matches intent; note array overwrite behavior

If arrays contain multiple primitives, the same key will be overwritten (last value wins). Confirm this mirrors agent desktop behavior.

Do we expect multi-valued arrays to be joined (e.g., comma-separated) instead of overwritten? If so, we can adjust getFlatParams accordingly.


296-304: Error handling is consistent

Logs via LoggerProxy and rethrow preserve context for Task.getErrorDetails.

Comment on lines 597 to 623
// Function to update IVR transcript button state
function updateIvrTranscriptButtonState() {
console.log('Updating IVR transcript button state...');

if (!currentTask) {
console.log('IVR Button State: No active task found');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'No active task';
return;
}
console.log('IVR current task ', currentTask);
console.log(`IVR Button State: Task status: ${currentTask.data.interaction.state}, Media channel: ${currentTask.data.mediaType}`);

if (currentTask.data.interaction.state === 'connected' && currentTask.data.mediaType === 'telephony') {
console.log('IVR Button State: Enabling button - task accepted and telephony channel');
fetchIvrTranscriptBtn.disabled = false;
ivrStatusElm.textContent = 'Ready to fetch IVR transcript';
} else if (currentTask.data.interaction.state !== 'connected') {
console.log('IVR Button State: Disabling button - task not accepted');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'Task must be accepted first';
} else if (currentTask.data.mediaType !== 'telephony') {
console.log('IVR Button State: Disabling button - not telephony channel');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'IVR transcript only available for telephony';
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: using currentTask.data.mediaType (should be currentTask.data.interaction.mediaType)

Task data stores media type under data.interaction.mediaType throughout the sample. Using data.mediaType will mis-detect channel and disable the button incorrectly.

Apply this diff:

-  console.log('IVR current task ', currentTask);
-  console.log(`IVR Button State: Task status: ${currentTask.data.interaction.state}, Media channel: ${currentTask.data.mediaType}`);
+  console.log('IVR current task ', currentTask);
+  console.log(`IVR Button State: Task status: ${currentTask.data.interaction.state}, Media channel: ${currentTask.data.interaction.mediaType}`);
 
-  if (currentTask.data.interaction.state === 'connected' && currentTask.data.mediaType === 'telephony') {
+  if (currentTask.data.interaction.state === 'connected' && currentTask.data.interaction.mediaType === 'telephony') {
     console.log('IVR Button State: Enabling button - task accepted and telephony channel');
     fetchIvrTranscriptBtn.disabled = false;
     ivrStatusElm.textContent = 'Ready to fetch IVR transcript';
   } else if (currentTask.data.interaction.state !== 'connected') {
     console.log('IVR Button State: Disabling button - task not accepted');
     fetchIvrTranscriptBtn.disabled = true;
     ivrStatusElm.textContent = 'Task must be accepted first';
-  } else if (currentTask.data.mediaType !== 'telephony') {
+  } else if (currentTask.data.interaction.mediaType !== 'telephony') {
     console.log('IVR Button State: Disabling button - not telephony channel');
     fetchIvrTranscriptBtn.disabled = true;
     ivrStatusElm.textContent = 'IVR transcript only available for telephony';
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Function to update IVR transcript button state
function updateIvrTranscriptButtonState() {
console.log('Updating IVR transcript button state...');
if (!currentTask) {
console.log('IVR Button State: No active task found');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'No active task';
return;
}
console.log('IVR current task ', currentTask);
console.log(`IVR Button State: Task status: ${currentTask.data.interaction.state}, Media channel: ${currentTask.data.mediaType}`);
if (currentTask.data.interaction.state === 'connected' && currentTask.data.mediaType === 'telephony') {
console.log('IVR Button State: Enabling button - task accepted and telephony channel');
fetchIvrTranscriptBtn.disabled = false;
ivrStatusElm.textContent = 'Ready to fetch IVR transcript';
} else if (currentTask.data.interaction.state !== 'connected') {
console.log('IVR Button State: Disabling button - task not accepted');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'Task must be accepted first';
} else if (currentTask.data.mediaType !== 'telephony') {
console.log('IVR Button State: Disabling button - not telephony channel');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'IVR transcript only available for telephony';
}
}
// Function to update IVR transcript button state
function updateIvrTranscriptButtonState() {
console.log('Updating IVR transcript button state...');
if (!currentTask) {
console.log('IVR Button State: No active task found');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'No active task';
return;
}
console.log('IVR current task ', currentTask);
console.log(`IVR Button State: Task status: ${currentTask.data.interaction.state}, Media channel: ${currentTask.data.interaction.mediaType}`);
if (currentTask.data.interaction.state === 'connected' && currentTask.data.interaction.mediaType === 'telephony') {
console.log('IVR Button State: Enabling button - task accepted and telephony channel');
fetchIvrTranscriptBtn.disabled = false;
ivrStatusElm.textContent = 'Ready to fetch IVR transcript';
} else if (currentTask.data.interaction.state !== 'connected') {
console.log('IVR Button State: Disabling button - task not accepted');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'Task must be accepted first';
} else if (currentTask.data.interaction.mediaType !== 'telephony') {
console.log('IVR Button State: Disabling button - not telephony channel');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'IVR transcript only available for telephony';
}
}
🤖 Prompt for AI Agents
In docs/samples/contact-center/app.js around lines 597 to 623, the code
incorrectly reads currentTask.data.mediaType (which is not where media type is
stored) causing wrong button state; change all uses to
currentTask.data.interaction.mediaType (including the console.log that reports
media channel and the conditional that checks for 'telephony') so the
media-channel detection and enabling/disabling logic use
currentTask.data.interaction.mediaType consistently.

Comment on lines 625 to 647
// Function to fetch IVR transcript
async function fetchIvrTranscript() {
console.log('=== Starting IVR Transcript Fetch ===');

// Check if task is accepted and media type is telephony
if (!currentTask) {
console.error('IVR Fetch: No active task found');
ivrStatusElm.textContent = 'No active task found';
return;
}

console.log(`IVR Fetch: Current task status: ${currentTask.data.interaction.state}, media channel: ${currentTask.data.mediaType}`);

if (currentTask.data.interaction.state !== 'connected') {
console.warn('IVR Fetch: Task is not accepted');
ivrStatusElm.textContent = 'Task must be accepted to fetch IVR transcript';
return;
}

if (currentTask.data.mediaType !== 'telephony') {
console.warn('IVR Fetch: Media channel is not telephony');
ivrStatusElm.textContent = 'IVR transcript is only available for telephony tasks';
return;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Bug: mediaType path again

Same issue in fetchIvrTranscript preconditions; fix to reference interaction.mediaType.

-  console.log(`IVR Fetch: Current task status: ${currentTask.data.interaction.state}, media channel: ${currentTask.data.mediaType}`);
+  console.log(`IVR Fetch: Current task status: ${currentTask.data.interaction.state}, media channel: ${currentTask.data.interaction.mediaType}`);
@@
-  if (currentTask.data.mediaType !== 'telephony') {
+  if (currentTask.data.interaction.mediaType !== 'telephony') {
     console.warn('IVR Fetch: Media channel is not telephony');
     ivrStatusElm.textContent = 'IVR transcript is only available for telephony tasks';
     return;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Function to fetch IVR transcript
async function fetchIvrTranscript() {
console.log('=== Starting IVR Transcript Fetch ===');
// Check if task is accepted and media type is telephony
if (!currentTask) {
console.error('IVR Fetch: No active task found');
ivrStatusElm.textContent = 'No active task found';
return;
}
console.log(`IVR Fetch: Current task status: ${currentTask.data.interaction.state}, media channel: ${currentTask.data.mediaType}`);
if (currentTask.data.interaction.state !== 'connected') {
console.warn('IVR Fetch: Task is not accepted');
ivrStatusElm.textContent = 'Task must be accepted to fetch IVR transcript';
return;
}
if (currentTask.data.mediaType !== 'telephony') {
console.warn('IVR Fetch: Media channel is not telephony');
ivrStatusElm.textContent = 'IVR transcript is only available for telephony tasks';
return;
// Function to fetch IVR transcript
async function fetchIvrTranscript() {
console.log('=== Starting IVR Transcript Fetch ===');
// Check if task is accepted and media type is telephony
if (!currentTask) {
console.error('IVR Fetch: No active task found');
ivrStatusElm.textContent = 'No active task found';
return;
}
console.log(`IVR Fetch: Current task status: ${currentTask.data.interaction.state}, media channel: ${currentTask.data.interaction.mediaType}`);
if (currentTask.data.interaction.state !== 'connected') {
console.warn('IVR Fetch: Task is not accepted');
ivrStatusElm.textContent = 'Task must be accepted to fetch IVR transcript';
return;
}
if (currentTask.data.interaction.mediaType !== 'telephony') {
console.warn('IVR Fetch: Media channel is not telephony');
ivrStatusElm.textContent = 'IVR transcript is only available for telephony tasks';
return;
}
// …rest of function…
}
🤖 Prompt for AI Agents
In docs/samples/contact-center/app.js around lines 625 to 647, the precondition
checks for media type are incorrectly reading currentTask.data.mediaType instead
of the mediaType nested under interaction; update the code to reference
currentTask.data.interaction.mediaType everywhere in this function (including
the console.log and the if-check that validates 'telephony') so the
media-channel checks use interaction.mediaType consistently and
ivrStatus/messages remain accurate.

Comment on lines 676 to 705
// Display the conversations
let html = '';
transcript.forEach((conversation, index) => {
console.log(`IVR Fetch: Processing conversation ${index + 1}:`, conversation);
html += `
<div style="margin-bottom: 20px; padding: 15px; border: 1px solid #ccc; border-radius: 5px;">
<h4>Conversation ${index + 1}</h4>
<p><strong>Call ID:</strong> ${conversation.callId || 'N/A'}</p>
<p><strong>Duration:</strong> ${conversation.duration || 'N/A'}</p>
<p><strong>Start Time:</strong> ${conversation.startTime || 'N/A'}</p>
<p><strong>End Time:</strong> ${conversation.endTime || 'N/A'}</p>
${conversation.segments && conversation.segments.length > 0 ? `
<div style="margin-top: 10px;">
<strong>Segments:</strong>
<ul style="margin-top: 5px;">
${conversation.segments.map(segment => `
<li style="margin-bottom: 5px;">
<strong>${segment.speaker || 'Unknown'}:</strong> ${segment.text || 'N/A'}
<br><small>Duration: ${segment.duration || 'N/A'}, Offset: ${segment.offset || 'N/A'}</small>
</li>
`).join('')}
</ul>
</div>
` : '<p><em>No segments available</em></p>'}
</div>
`;
});

ivrResultsContentElm.innerHTML = html;
console.log('IVR Fetch: UI updated with conversation data');
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

XSS risk: unescaped user text inserted via innerHTML

Transcript fields (e.g., customer query) originate from user speech-to-text and must be treated as untrusted. Direct innerHTML interpolation is vulnerable to XSS. Escape values or assign via textContent/DOM nodes.

Apply minimal escaping in this block:

-            <p><strong>Call ID:</strong> ${conversation.callId || 'N/A'}</p>
-            <p><strong>Duration:</strong> ${conversation.duration || 'N/A'}</p>
-            <p><strong>Start Time:</strong> ${conversation.startTime || 'N/A'}</p>
-            <p><strong>End Time:</strong> ${conversation.endTime || 'N/A'}</p>
+            <p><strong>Call ID:</strong> ${escapeHtml(conversation.callId || 'N/A')}</p>
+            <p><strong>Duration:</strong> ${escapeHtml(conversation.duration || 'N/A')}</p>
+            <p><strong>Start Time:</strong> ${escapeHtml(conversation.startTime || 'N/A')}</p>
+            <p><strong>End Time:</strong> ${escapeHtml(conversation.endTime || 'N/A')}</p>
@@
-                  ${conversation.segments.map(segment => `
+                  ${conversation.segments.map(segment => `
                     <li style="margin-bottom: 5px;">
-                      <strong>${segment.speaker || 'Unknown'}:</strong> ${segment.text || 'N/A'}
-                      <br><small>Duration: ${segment.duration || 'N/A'}, Offset: ${segment.offset || 'N/A'}</small>
+                      <strong>${escapeHtml(segment.speaker || 'Unknown')}:</strong> ${escapeHtml(segment.text || 'N/A')}
+                      <br><small>Duration: ${escapeHtml(segment.duration || 'N/A')}, Offset: ${escapeHtml(segment.offset || 'N/A')}</small>
                     </li>
                   `).join('')}

Place this helper near the top of the file (or before usage):

// Escapes HTML special characters to prevent XSS in sample UI
function escapeHtml(value) {
  return String(value)
    .replace(/&/g, '&amp;')
    .replace(/</g, '&lt;')
    .replace(/>/g, '&gt;')
    .replace(/"/g, '&quot;')
    .replace(/'/g, '&#39;');
}

Note: The service returns IvrConversations as an array of turns ({customer?}/{bot?}). Consider rendering those turns directly in the UI instead of assuming conversation.segments/callId/duration, which may be undefined.

🤖 Prompt for AI Agents
In docs/samples/contact-center/app.js around lines 676-705, the code injects
transcript data directly into innerHTML leading to XSS risk; add an escapeHtml
helper near the top of the file (before usage) that replaces &, <, >, ", ' with
their HTML entities, then update this block to avoid raw innerHTML interpolation
by either building DOM nodes and setting textContent for all user-derived fields
(callId, duration, startTime, endTime, segment.speaker, segment.text, etc.) or,
if continuing to build a string, pass every user-derived value through
escapeHtml before inserting it; also handle missing fields safely and prefer
rendering turns (customer/bot) if available instead of assuming
segments/callId/duration exist.

Comment on lines 1042 to 1159
* task.data.interactionId,
* 5
* );
* console.log('IVR transcript fetched successfully');
*
* // Process conversation data
* transcript.forEach((turn, index) => {
* console.log(`Turn ${index + 1}:`);
* if (turn.customer) {
* console.log(` Customer: ${turn.customer.query}`);
* }
* if (turn.bot) {
* console.log(` Bot: ${turn.bot.reply} (Bot: ${turn.bot.botName})`);
* }
* });
* } catch (error) {
* console.error('Failed to fetch IVR transcript:', error);
* }
* });
* ```
*/
public async fetchIvrTranscript(
orgId: string,
interactionId: string,
timeoutMins: number
): Promise<IvrTranscriptResponse> {
try {
LoggerProxy.info(`Fetching IVR transcript using direct IvrTranscriptService`, {
module: TASK_FILE,
method: METHODS.FETCH_IVR_TRANSCRIPT,
interactionId,
});

// Start metrics tracking
this.metricsManager.timeEvent([
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_SUCCESS,
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
]);

// Validate required fields
if (!orgId || !interactionId) {
throw new Error('Organization ID or Interaction ID is missing');
}

// Use IvrTranscriptService directly (matching agent desktop fetchIVRTranscript)
const transcriptConversations = await this.getIvrTranscriptService().fetchIVRTranscript(
orgId,
interactionId,
timeoutMins
);

// Track success metrics
this.metricsManager.trackEvent(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_SUCCESS,
{
taskId: interactionId,
orgId,
type: 'direct-service',
conversationTurns: transcriptConversations.length,
...MetricsManager.getCommonTrackingFieldForAQMResponse(this.data),
},
['operational', 'behavioral', 'business']
);

LoggerProxy.log('IVR transcript fetched successfully using direct service', {
module: TASK_FILE,
method: METHODS.FETCH_IVR_TRANSCRIPT,
interactionId,
});

return transcriptConversations;
} catch (error) {
const {error: detailedError} = getErrorDetails(
error,
METHODS.FETCH_IVR_TRANSCRIPT,
TASK_FILE
);

// Track failure metrics
this.metricsManager.trackEvent(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
{
taskId: interactionId,
orgId,
type: 'direct-service',
error: detailedError.message,
...MetricsManager.getCommonTrackingFieldForAQMResponse(this.data),
},
['operational', 'behavioral', 'business']
);

LoggerProxy.error(`IVR transcript fetch failed: ${detailedError.message}`, {
module: TASK_FILE,
method: METHODS.FETCH_IVR_TRANSCRIPT,
interactionId,
});

throw detailedError;
}
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Add media-type guard and fix failure metrics payload mapping

  • Add a precondition: IVR transcripts should only be fetched for telephony tasks to avoid unnecessary network calls and clearer error messaging.
  • For failure metrics, use getCommonTrackingFieldForAQMResponseFailed(...) to align with the established pattern elsewhere in Task.
   public async fetchIvrTranscript(
     orgId: string,
     interactionId: string,
     timeoutMins: number
   ): Promise<IvrTranscriptResponse> {
     try {
@@
       // Validate required fields
       if (!orgId || !interactionId) {
         throw new Error('Organization ID or Interaction ID is missing');
       }
 
+      // Enforce voice-only usage for IVR transcript
+      if (this.data?.interaction?.mediaType !== MEDIA_CHANNEL.TELEPHONY) {
+        throw new Error('IVR transcript is only available for voice (telephony) tasks');
+      }
+
       // Use IvrTranscriptService directly (matching agent desktop fetchIVRTranscript)
       const transcriptConversations = await this.getIvrTranscriptService().fetchIVRTranscript(
         orgId,
         interactionId,
         timeoutMins
       );
@@
       // Track failure metrics
       this.metricsManager.trackEvent(
         METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
         {
           taskId: interactionId,
           orgId,
           type: 'direct-service',
           error: detailedError.message,
-          ...MetricsManager.getCommonTrackingFieldForAQMResponse(this.data),
+          ...MetricsManager.getCommonTrackingFieldForAQMResponseFailed((error as any)?.details || {}),
         },
         ['operational', 'behavioral', 'business']
       );
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* Fetches the IVR transcript for the specified task.
* This method retrieves the Interactive Voice Response transcript that was recorded
* during the customer's interaction with the IVR system before being connected to an agent.
* Available for tasks that have IVR interactions.
*
* @param orgId - Organization ID (required)
* @param interactionId - Interaction ID for the task (required)
* @param timeoutMins - Timeout in minutes for the transcript URL (required)
* @returns Promise<IvrTranscriptResponse> The IVR transcript conversation data
* @throws Error if no IVR transcript is available, or the fetch operation fails
* @example
* ```typescript
* // Fetch IVR transcript after accepting a task
* task.on(TASK_EVENTS.TASK_ASSIGNED, async () => {
* try {
* const transcript = await task.fetchIvrTranscript(
* task.data.orgId,
* task.data.interactionId,
* 5
* );
* console.log('IVR transcript fetched successfully');
*
* // Process conversation data
* transcript.forEach((turn, index) => {
* console.log(`Turn ${index + 1}:`);
* if (turn.customer) {
* console.log(` Customer: ${turn.customer.query}`);
* }
* if (turn.bot) {
* console.log(` Bot: ${turn.bot.reply} (Bot: ${turn.bot.botName})`);
* }
* });
* } catch (error) {
* console.error('Failed to fetch IVR transcript:', error);
* }
* });
* ```
*/
public async fetchIvrTranscript(
orgId: string,
interactionId: string,
timeoutMins: number
): Promise<IvrTranscriptResponse> {
try {
LoggerProxy.info(`Fetching IVR transcript using direct IvrTranscriptService`, {
module: TASK_FILE,
method: METHODS.FETCH_IVR_TRANSCRIPT,
interactionId,
});
// Start metrics tracking
this.metricsManager.timeEvent([
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_SUCCESS,
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
]);
// Validate required fields
if (!orgId || !interactionId) {
throw new Error('Organization ID or Interaction ID is missing');
}
// Use IvrTranscriptService directly (matching agent desktop fetchIVRTranscript)
const transcriptConversations = await this.getIvrTranscriptService().fetchIVRTranscript(
orgId,
interactionId,
timeoutMins
);
// Track success metrics
this.metricsManager.trackEvent(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_SUCCESS,
{
taskId: interactionId,
orgId,
type: 'direct-service',
conversationTurns: transcriptConversations.length,
...MetricsManager.getCommonTrackingFieldForAQMResponse(this.data),
},
['operational', 'behavioral', 'business']
);
LoggerProxy.log('IVR transcript fetched successfully using direct service', {
module: TASK_FILE,
method: METHODS.FETCH_IVR_TRANSCRIPT,
interactionId,
});
return transcriptConversations;
} catch (error) {
const {error: detailedError} = getErrorDetails(
error,
METHODS.FETCH_IVR_TRANSCRIPT,
TASK_FILE
);
// Track failure metrics
this.metricsManager.trackEvent(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
{
taskId: interactionId,
orgId,
type: 'direct-service',
error: detailedError.message,
...MetricsManager.getCommonTrackingFieldForAQMResponse(this.data),
},
['operational', 'behavioral', 'business']
);
LoggerProxy.error(`IVR transcript fetch failed: ${detailedError.message}`, {
module: TASK_FILE,
method: METHODS.FETCH_IVR_TRANSCRIPT,
interactionId,
});
throw detailedError;
}
}
public async fetchIvrTranscript(
orgId: string,
interactionId: string,
timeoutMins: number
): Promise<IvrTranscriptResponse> {
try {
LoggerProxy.info(`Fetching IVR transcript using direct IvrTranscriptService`, {
module: TASK_FILE,
method: METHODS.FETCH_IVR_TRANSCRIPT,
interactionId,
});
// Start metrics tracking
this.metricsManager.timeEvent([
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_SUCCESS,
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
]);
// Validate required fields
if (!orgId || !interactionId) {
throw new Error('Organization ID or Interaction ID is missing');
}
// Enforce voice-only usage for IVR transcript
if (this.data?.interaction?.mediaType !== MEDIA_CHANNEL.TELEPHONY) {
throw new Error('IVR transcript is only available for voice (telephony) tasks');
}
// Use IvrTranscriptService directly (matching agent desktop fetchIVRTranscript)
const transcriptConversations = await this.getIvrTranscriptService().fetchIVRTranscript(
orgId,
interactionId,
timeoutMins
);
// Track success metrics
this.metricsManager.trackEvent(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_SUCCESS,
{
taskId: interactionId,
orgId,
type: 'direct-service',
conversationTurns: transcriptConversations.length,
...MetricsManager.getCommonTrackingFieldForAQMResponse(this.data),
},
['operational', 'behavioral', 'business']
);
LoggerProxy.log('IVR transcript fetched successfully using direct service', {
module: TASK_FILE,
method: METHODS.FETCH_IVR_TRANSCRIPT,
interactionId,
});
return transcriptConversations;
} catch (error) {
const {error: detailedError} = getErrorDetails(
error,
METHODS.FETCH_IVR_TRANSCRIPT,
TASK_FILE
);
// Track failure metrics
this.metricsManager.trackEvent(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
{
taskId: interactionId,
orgId,
type: 'direct-service',
error: detailedError.message,
...MetricsManager.getCommonTrackingFieldForAQMResponseFailed((error as any)?.details || {}),
},
['operational', 'behavioral', 'business']
);
LoggerProxy.error(`IVR transcript fetch failed: ${detailedError.message}`, {
module: TASK_FILE,
method: METHODS.FETCH_IVR_TRANSCRIPT,
interactionId,
});
throw detailedError;
}
}
🤖 Prompt for AI Agents
In packages/@webex/plugin-cc/src/services/task/index.ts around lines 1042 to
1159, add a precondition to only attempt fetching IVR transcripts for telephony
tasks: check this.data.mediaType (or equivalent media-type field) and throw a
descriptive Error if the task is not telephony to avoid unnecessary network
calls; then, in the catch block replace
MetricsManager.getCommonTrackingFieldForAQMResponse(this.data) with
MetricsManager.getCommonTrackingFieldForAQMResponseFailed(this.data) when
building the failure metrics payload so it follows the established failure
pattern.

Comment on lines 55 to 62
uri: `https://mediastorage.produs1.ciscoccservice.com/media/organization/${orgId}/interaction/${interactionId}/ivrtranscript?timeOutMins=${timeOutMins}`,
method: HTTP_METHODS.GET,
headers: {
'cisco-no-http-redirect': null,
'spark-user-agent': null,
Authorization: await this.webex.credentials.getUserToken(),
},
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Verification agent

🧩 Analysis chain

Headers: avoid null header values; verify Authorization format

  • Setting headers to null will send invalid header values on some transports. Remove those keys instead of setting null.
  • Authorization: confirm what webex.credentials.getUserToken() returns (raw token vs “Bearer ”). If interceptors already attach Authorization, you can omit it entirely.

Apply this safer header construction:

-      const requestPayload: WebexRequestPayload = {
+      const requestPayload: WebexRequestPayload = {
         uri: `https://mediastorage.produs1.ciscoccservice.com/media/organization/${orgId}/interaction/${interactionId}/ivrtranscript?timeOutMins=${timeOutMins}`,
         method: HTTP_METHODS.GET,
-        headers: {
-          'cisco-no-http-redirect': null,
-          'spark-user-agent': null,
-          Authorization: await this.webex.credentials.getUserToken(),
-        },
+        headers: {
+          // Prefer relying on Webex interceptors; if needed, ensure this is 'Bearer <token>'
+          Authorization: await this.webex.credentials.getUserToken(),
+        },
       };

Additionally, please verify the token format:


🌐 Web query:

In the Webex JS SDK, does webex.credentials.getUserToken() return a string already prefixed with 'Bearer ', or should callers prepend 'Bearer ' to the returned access token when setting the Authorization header?

💡 Result:

The webex.credentials.getUserToken() method returns the raw access token string only—it does not include the 'Bearer ' prefix[3][9]. When setting the Authorization header for Webex REST API calls, callers must prepend 'Bearer ' to the returned access token[2][4].

Essential context and supporting details:

  • Webex REST API requests require the access token to be formatted as Authorization: Bearer <ACCESS_TOKEN>[2][4].
  • SDK documentation and sample applications demonstrate initializing with {access_token: "<YOUR-ACCESS-TOKEN-HERE>"} but always show API usage with the explicit 'Bearer ' prefix when constructing HTTP headers[3][9].
  • The prefix 'Bearer ' is required by OAuth 2.0 specifications to distinguish bearer tokens from other authentication schemes[4].
  • There is no documentation or usage pattern indicating that the SDK's access token is returned with the prefix, nor do sample codes show using the token directly in the header without manual prefixing[3][9].

Example:

const token = await webex.credentials.getUserToken();
const headers = {
  Authorization: `Bearer ${token}`
};

Relevant information:

  • This pattern aligns with both official Webex API documentation and general OAuth practices[2][4].
  • Personal access tokens or tokens returned by the SDK should be treated securely and only presented in HTTP headers as specified[2].

Summary Table:

SDK Method Token Format Returned Header Usage Required
webex.credentials.getUserToken() Access token (no prefix) Authorization: Bearer <TOKEN>

Conclusion:
Always manually prepend 'Bearer ' to the raw token returned from webex.credentials.getUserToken() when setting the Authorization header for Webex REST API calls[2][4][3][9].

Citations:


Avoid null header values and prepend “Bearer ” to the Authorization token

  • Remove the cisco-no-http-redirect and spark-user-agent keys rather than setting them to null.
  • webex.credentials.getUserToken() returns only the raw token; you must prepend "Bearer " when constructing the header.
  • If Core interceptors already inject these headers (and Authorization), you can drop the entire headers block—otherwise explicitly set only what’s needed.

Apply this patch:

       const requestPayload: WebexRequestPayload = {
         uri: `https://mediastorage.produs1.ciscoccservice.com/media/organization/${orgId}/interaction/${interactionId}/ivrtranscript?timeOutMins=${timeOutMins}`,
         method: HTTP_METHODS.GET,
-        headers: {
-          'cisco-no-http-redirect': null,
-          'spark-user-agent': null,
-          Authorization: await this.webex.credentials.getUserToken(),
-        },
+        headers: {
+          Authorization: `Bearer ${await this.webex.credentials.getUserToken()}`,
+        },
       };
🤖 Prompt for AI Agents
In packages/@webex/plugin-cc/src/services/task/IvrTranscriptService.ts around
lines 55 to 62, the request headers set null-valued keys and pass a raw token;
remove the 'cisco-no-http-redirect' and 'spark-user-agent' entries instead of
setting them to null, and set Authorization to "Bearer " + (await
this.webex.credentials.getUserToken()); alternatively, if Core interceptors
already inject these headers (including Authorization), remove the entire
headers block—otherwise include only the necessary header(s) with the
Bearer-prefixed token.

Comment on lines 1214 to 1248
/**
* Fetches the IVR transcript for the current voice task
* This method retrieves the Interactive Voice Response transcript that was recorded
* during the customer's interaction with the IVR system before being connected to an agent.
* Only available for voice tasks that have IVR interactions.
*
* @param orgId - Organization ID (required)
* @param interactionId - Interaction ID for the task (required)
* @param timeoutMins - Timeout in minutes for the transcript URL (required)
* @param includeConversation - Whether to fetch the actual conversation data
* @returns Promise<IvrTranscriptResponse> The IVR transcript data including metadata and content
* @throws Error if the task is not a voice task, no IVR transcript is available, or the fetch operation fails
* @example
* ```typescript
* // Fetch IVR transcript after accepting a voice task
* try {
* const transcript = await task.fetchIvrTranscript('org123', 'interaction456', 10);
* console.log('IVR transcript:', transcript.content.transcripts);
*
* // Display transcript to agent
* transcript.content.transcripts.forEach(entry => {
* console.log(`${entry.timestamp}: ${entry.utterance}`);
* });
* } catch (error) {
* console.error('Failed to fetch IVR transcript:', error);
* // Handle case where no transcript is available
* }
* ```
*/
fetchIvrTranscript(
orgId: string,
interactionId: string,
timeoutMins: number
): Promise<IvrTranscriptResponse>;
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Fix JSDoc: signature and example do not match the actual return type

  • The doc lists an extra parameter includeConversation that the method does not accept.
  • The example treats the return as { content: { transcripts: ... } }, but the method returns IvrTranscriptResponse (an array of conversation turns).

This will mislead SDK consumers.

Suggested fix:

   /**
    * Fetches the IVR transcript for the current voice task
    * This method retrieves the Interactive Voice Response transcript that was recorded
    * during the customer's interaction with the IVR system before being connected to an agent.
    * Only available for voice tasks that have IVR interactions.
    *
    * @param orgId - Organization ID (required)
    * @param interactionId - Interaction ID for the task (required)
    * @param timeoutMins - Timeout in minutes for the transcript URL (required)
-   * @param includeConversation - Whether to fetch the actual conversation data
-   * @returns Promise<IvrTranscriptResponse> The IVR transcript data including metadata and content
+   * @returns Promise<IvrTranscriptResponse> The IVR conversation turns array
    * @throws Error if the task is not a voice task, no IVR transcript is available, or the fetch operation fails
    * @example
    * ```typescript
    * // Fetch IVR transcript after accepting a voice task
    * try {
-   *   const transcript = await task.fetchIvrTranscript('org123', 'interaction456', 10);
-   *   console.log('IVR transcript:', transcript.content.transcripts);
-   *
-   *   // Display transcript to agent
-   *   transcript.content.transcripts.forEach(entry => {
-   *     console.log(`${entry.timestamp}: ${entry.utterance}`);
-   *   });
+   *   const transcript = await task.fetchIvrTranscript('org123', 'interaction456', 10);
+   *   console.log('Turns:', transcript.length);
+   *   transcript.forEach((turn) => {
+   *     if (turn.customer) {
+   *       console.log(`${turn.customer.timestamp}: Customer — ${turn.customer.query}`);
+   *     }
+   *     if (turn.bot) {
+   *       console.log(`${turn.bot.timestamp}: Bot (${turn.bot.botName}) — ${turn.bot.reply}`);
+   *     }
+   *   });
    * } catch (error) {
    *   console.error('Failed to fetch IVR transcript:', error);
    *   // Handle case where no transcript is available
    * }
    * ```
    */
🤖 Prompt for AI Agents
In packages/@webex/plugin-cc/src/services/task/types.ts around lines 1214 to
1248, the JSDoc for fetchIvrTranscript incorrectly documents an extra parameter
includeConversation and shows an example that treats the return as { content: {
transcripts: ... } } while the method signature accepts only (orgId,
interactionId, timeoutMins) and returns IvrTranscriptResponse (an array of
conversation turns); update the JSDoc by removing the includeConversation
@param, correct the @returns to state it returns Promise<IvrTranscriptResponse>
(array of turns), and replace the example with one that treats the result as an
array (e.g., log transcript.length and iterate turns, checking turn.customer and
turn.bot fields and printing timestamps/queries/replies accordingly).

Comment on lines 89 to 90
getIvrTranscriptUrl: jest.fn().mockResolvedValue({}),
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Stale contactMock API for IVR

Task.fetchIvrTranscript delegates directly to IvrTranscriptService (via getIvrTranscriptService), it doesn’t use contact.getIvrTranscriptUrl anymore. Tests that rely on contactMock.getIvrTranscriptUrl to simulate IVR failures or empties won’t be exercising the real path and are brittle.

As part of the IVR tests below, replace usages of contactMock.getIvrTranscriptUrl with either:

  • overriding (task as any).getIvrTranscriptService() to return a mock with fetchIVRTranscript (resolve/reject), or
  • mocking WebexRequest.getInstance().request to return metadata responses.

I’ve proposed concrete diffs in those tests.

🤖 Prompt for AI Agents
In packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts around lines
89 to 90, the tests use the stale contactMock.getIvrTranscriptUrl API but
Task.fetchIvrTranscript now delegates to IvrTranscriptService via
getIvrTranscriptService; update the tests to stop stubbing
contact.getIvrTranscriptUrl and instead either override (task as
any).getIvrTranscriptService() to return a mock object that provides
fetchIVRTranscript which you can resolve or reject as needed, or mock
WebexRequest.getInstance().request to return the appropriate metadata responses
for the IVR flow so the tests exercise the real delegation path.

Comment on lines 1585 to 1607
it('should handle API error when fetching transcript URL', async () => {
const taskDataMockVoice = {
...taskDataMock,
mediaChannel: MEDIA_CHANNEL.TELEPHONY,
orgId: 'test-org-123',
taskId: 'test-task-456',
};

const task = new Task(contactMock, webCallingService, taskDataMockVoice, { wrapUpProps: null });

const apiError = new Error('API Error');
contactMock.getIvrTranscriptUrl.mockRejectedValue(apiError);

await expect(task.fetchIvrTranscript('test-org-123', 'test-interaction-456', 5)).rejects.toThrow();

expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
expect.objectContaining({
error: expect.any(String)
}),
['operational', 'behavioral', 'business']
);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Test incorrectly mocks contact API for IVR failure; update to mock IvrTranscriptService

Task no longer calls contact.getIvrTranscriptUrl. This test passes for the wrong reason (likely due to undefined metadata), making it brittle and misleading.

Apply this diff to correctly simulate the IVR service failure:

-      const apiError = new Error('API Error');
-      contactMock.getIvrTranscriptUrl.mockRejectedValue(apiError);
+      const apiError = new Error('API Error');
+      // Override the private IVR service to reject
+      (task as any).getIvrTranscriptService = jest.fn().mockReturnValue({
+        fetchIVRTranscript: jest.fn().mockRejectedValue(apiError),
+      });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should handle API error when fetching transcript URL', async () => {
const taskDataMockVoice = {
...taskDataMock,
mediaChannel: MEDIA_CHANNEL.TELEPHONY,
orgId: 'test-org-123',
taskId: 'test-task-456',
};
const task = new Task(contactMock, webCallingService, taskDataMockVoice, { wrapUpProps: null });
const apiError = new Error('API Error');
contactMock.getIvrTranscriptUrl.mockRejectedValue(apiError);
await expect(task.fetchIvrTranscript('test-org-123', 'test-interaction-456', 5)).rejects.toThrow();
expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
expect.objectContaining({
error: expect.any(String)
}),
['operational', 'behavioral', 'business']
);
});
it('should handle API error when fetching transcript URL', async () => {
const taskDataMockVoice = {
...taskDataMock,
mediaChannel: MEDIA_CHANNEL.TELEPHONY,
orgId: 'test-org-123',
taskId: 'test-task-456',
};
const task = new Task(contactMock, webCallingService, taskDataMockVoice, { wrapUpProps: null });
const apiError = new Error('API Error');
// Override the private IVR service to reject
(task as any).getIvrTranscriptService = jest.fn().mockReturnValue({
fetchIVRTranscript: jest.fn().mockRejectedValue(apiError),
});
await expect(task.fetchIvrTranscript('test-org-123', 'test-interaction-456', 5)).rejects.toThrow();
expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
expect.objectContaining({
error: expect.any(String)
}),
['operational', 'behavioral', 'business']
);
});

Comment on lines 1645 to 1677
it('should handle empty transcripts array', async () => {
const taskDataMockVoice = {
...taskDataMock,
mediaChannel: MEDIA_CHANNEL.TELEPHONY,
orgId: 'test-org-123',
taskId: 'test-task-456',
};

const task = new Task(contactMock, webCallingService, taskDataMockVoice, { wrapUpProps: null });

const mockTranscriptUrlResponse = {
body: {
orgId: 'test-org-123',
interactionId: 'test-interaction-456',
timeOutMins: 5,
transcripts: []
}
};

contactMock.getIvrTranscriptUrl.mockResolvedValue(mockTranscriptUrlResponse);

await expect(task.fetchIvrTranscript('test-org-123', 'test-interaction-456', 5)).rejects.toThrow();

expect(global.fetch).not.toHaveBeenCalled();
expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
expect.objectContaining({
taskId: 'test-interaction-456',
error: expect.any(String)
}),
['operational', 'behavioral', 'business']
);
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Wrong expectation: empty transcript metadata should not throw

IvrTranscriptService returns [] when meta.transcripts is empty; Task tracks success and returns []. This test currently expects a throw and relies on contactMock.getIvrTranscriptUrl, which Task does not use.

Update the test to mock IvrTranscriptService and assert a non-throwing empty result + success metric:

-    it('should handle empty transcripts array', async () => {
-      const taskDataMockVoice = {
-        ...taskDataMock,
-        mediaChannel: MEDIA_CHANNEL.TELEPHONY,
-        orgId: 'test-org-123',
-        taskId: 'test-task-456',
-      };
-
-      const task = new Task(contactMock, webCallingService, taskDataMockVoice, { wrapUpProps: null });
-
-      const mockTranscriptUrlResponse = {
-        body: {
-          orgId: 'test-org-123',
-          interactionId: 'test-interaction-456',
-          timeOutMins: 5,
-          transcripts: []
-        }
-      };
-
-      contactMock.getIvrTranscriptUrl.mockResolvedValue(mockTranscriptUrlResponse);
-
-      await expect(task.fetchIvrTranscript('test-org-123', 'test-interaction-456', 5)).rejects.toThrow();
-
-      expect(global.fetch).not.toHaveBeenCalled();
-      expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
-        METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
-        expect.objectContaining({
-          taskId: 'test-interaction-456',
-          error: expect.any(String)
-        }),
-        ['operational', 'behavioral', 'business']
-      );
-    });
+    it('should return empty array (and record success metric) when no transcripts are available', async () => {
+      const taskDataMockVoice = {
+        ...taskDataMock,
+        interaction: {
+          ...taskDataMock.interaction,
+          mediaType: MEDIA_CHANNEL.TELEPHONY
+        },
+        orgId: 'test-org-123',
+        interactionId: 'test-interaction-456',
+      };
+
+      const task = new Task(contactMock, webCallingService, taskDataMockVoice, { wrapUpProps: null });
+      // Service returns no conversations when metadata has no transcripts
+      (task as any).getIvrTranscriptService = jest.fn().mockReturnValue({
+        fetchIVRTranscript: jest.fn().mockResolvedValue([]),
+      });
+
+      const result = await task.fetchIvrTranscript('test-org-123', 'test-interaction-456', 5);
+      expect(result).toEqual([]);
+      expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
+        METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_SUCCESS,
+        expect.objectContaining({
+          taskId: 'test-interaction-456',
+          orgId: 'test-org-123',
+          conversationTurns: 0
+        }),
+        ['operational', 'behavioral', 'business']
+      );
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should handle empty transcripts array', async () => {
const taskDataMockVoice = {
...taskDataMock,
mediaChannel: MEDIA_CHANNEL.TELEPHONY,
orgId: 'test-org-123',
taskId: 'test-task-456',
};
const task = new Task(contactMock, webCallingService, taskDataMockVoice, { wrapUpProps: null });
const mockTranscriptUrlResponse = {
body: {
orgId: 'test-org-123',
interactionId: 'test-interaction-456',
timeOutMins: 5,
transcripts: []
}
};
contactMock.getIvrTranscriptUrl.mockResolvedValue(mockTranscriptUrlResponse);
await expect(task.fetchIvrTranscript('test-org-123', 'test-interaction-456', 5)).rejects.toThrow();
expect(global.fetch).not.toHaveBeenCalled();
expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
expect.objectContaining({
taskId: 'test-interaction-456',
error: expect.any(String)
}),
['operational', 'behavioral', 'business']
);
});
it('should return empty array (and record success metric) when no transcripts are available', async () => {
const taskDataMockVoice = {
...taskDataMock,
interaction: {
...taskDataMock.interaction,
mediaType: MEDIA_CHANNEL.TELEPHONY
},
orgId: 'test-org-123',
interactionId: 'test-interaction-456',
};
const task = new Task(contactMock, webCallingService, taskDataMockVoice, { wrapUpProps: null });
// Service returns no conversations when metadata has no transcripts
(task as any).getIvrTranscriptService = jest.fn().mockReturnValue({
fetchIVRTranscript: jest.fn().mockResolvedValue([]),
});
const result = await task.fetchIvrTranscript('test-org-123', 'test-interaction-456', 5);
expect(result).toEqual([]);
expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_SUCCESS,
expect.objectContaining({
taskId: 'test-interaction-456',
orgId: 'test-org-123',
conversationTurns: 0
}),
['operational', 'behavioral', 'business']
);
});
🤖 Prompt for AI Agents
In packages/@webex/plugin-cc/test/unit/spec/services/task/index.ts around lines
1645-1677, the test currently expects fetchIvrTranscript to throw and mocks
contactMock.getIvrTranscriptUrl, but Task uses IvrTranscriptService and should
return an empty array and track success. Update the test to mock
IvrTranscriptService to return an empty transcripts array (or resolve to []),
change the assertion to await
expect(task.fetchIvrTranscript(...)).resolves.toEqual([]) (no throw), keep the
check that global.fetch was not called, and assert that
mockMetricsManager.trackEvent was called with the success metric (e.g.,
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCHED) and an object containing the
task/interaction id and appropriate success info.

Comment on lines 5 to 9
/**
* Service for retrieving IVR (Interactive Voice Response) transcript data
* Handles fetching transcript metadata and conversation content from storage endpoints
* Provides structured access to bot conversation history and customer interactions
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please improve this

Comment on lines +1077 to +1081
// Start metrics tracking
this.metricsManager.timeEvent([
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_SUCCESS,
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-visit for metrics

Comment on lines 1093 to 1097
const transcriptConversations = await this.getIvrTranscriptService().fetchIVRTranscript(
orgId,
interactionId,
timeoutMins
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let break this into two steps

const transcriptService = this.getIvrTranscriptService();
const transcriptConversations = await fetchIVRTranscript(....);

{
taskId: interactionId,
orgId,
type: 'direct-service',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's confirm this value

Comment on lines 1127 to 1137
this.metricsManager.trackEvent(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
{
taskId: interactionId,
orgId,
type: 'direct-service',
error: detailedError.message,
...MetricsManager.getCommonTrackingFieldForAQMResponseFailed(error.details || {}),
},
['operational', 'behavioral', 'business']
);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upload log should happen for the error case and metric should contain the feedbackId

Comment on lines 86 to 91
console.log('IVR Transcript UI elements initialized:', {
fetchButton: !!fetchIvrTranscriptBtn,
timeoutInput: !!ivrTimeoutInput,
statusElement: !!ivrStatusElm,
resultsElement: !!ivrResultsContentElm
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please check all the console logs and keep only relevant ones

Comment on lines 611 to 620
console.log('IVR Button State: Enabling button - task accepted and telephony channel');
fetchIvrTranscriptBtn.disabled = false;
// Don't override status if auto-fetch is in progress
if (!ivrStatusElm.textContent.includes('Auto-fetching') && !ivrStatusElm.textContent.includes('Auto-fetched')) {
ivrStatusElm.textContent = 'Ready to fetch IVR transcript';
}
} else if (currentTask.data.interaction.state !== 'connected') {
console.log('IVR Button State: Disabling button - task not accepted');
fetchIvrTranscriptBtn.disabled = true;
ivrStatusElm.textContent = 'Task must be accepted first';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Discuss the buttons' visibility with Ravi and use the control visibility properties coming with the task object.

Comment on lines 790 to 794
// Auto-fetch IVR transcript for telephony tasks that are assigned (accepted)
if (task.data.interaction.mediaType === 'telephony' && task.data.interaction.state === 'connected') {
console.log('Auto-fetching IVR transcript for assigned telephony task...');
await autoFetchIvrTranscript(task, 'task assigned');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's ensure we are making the API call at only one place. We are calling at answer method as well, let's make it conditional to cover different type of station login

Comment on lines +842 to +846
// Auto-fetch IVR transcript for telephony consult acceptance
if (task.data.interaction.mediaType === 'telephony') {
console.log('Auto-fetching IVR transcript for consulted telephony task...');
await autoFetchIvrTranscript(task, 'consult accepted');
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, make sure we are calling it only once

Comment on lines 2068 to 2070
// Example usage after fetching transcript:
// const html = renderIVRTranscript(conversation);
// document.getElementById('ivr-transcript-container').innerHTML = html;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove this comment

const mockOrgId = 'org123';
const mockInteractionId = 'interaction456';
const mockTimeOutMins = 5;
const mockTranscriptPath = 'https://mediastorage.produs1.ciscoccservice.com/transcript.json';
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's create a separate file for the mock transcript (fixture) and add the response there. Based on the file path, we should expect the response matching the data in that file.

Example of the fixture - packages/@webex/plugin-meetings/test/unit/spec/fixture/locus.js


(webex.request as jest.Mock)
.mockResolvedValueOnce(mockResponse)
.mockResolvedValueOnce(mockConversationResponse);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can remove the 2nd mock as we are hardcoding the response from the file


expect(webex.request).toHaveBeenCalledTimes(2);
expect(webex.request).toHaveBeenCalledWith({
uri: `https://mediastorage.produs1.ciscoccservice.com/media/organization/${mockOrgId}/interaction/${mockInteractionId}/ivrtranscript?timeOutMins=${mockTimeOutMins}`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here the base path would be the file path defined

],
};

const mockResponse = {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mockMetaDataResponse

Comment on lines +189 to +197
transcripts: [
mockTranscriptMetadata,
{
...mockTranscriptMetadata,
transcriptId: 'trans2',
transcriptPath: 'https://example.com/transcript2.json',
botName: 'Bot2',
},
],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's make the array contain 3 transcripts. Fail the 2nd one and compare the result with 1st and 3rd ones

expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
expect.objectContaining({
error: expect.any(String)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add proper error message

const apiError = new Error('API Error');
mockIvrTranscriptService.fetchIVRTranscript.mockRejectedValue(apiError);

await expect(task.fetchIvrTranscript('test-org-123', 'test-interaction-456', 5)).rejects.toThrow();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can add the error details in the toThrow()

expect(mockMetricsManager.trackEvent).toHaveBeenCalledWith(
METRIC_EVENT_NAMES.TASK_IVR_TRANSCRIPT_FETCH_FAILED,
expect.objectContaining({
error: expect.any(String)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, we can add error details

const task = new Task(contactMock, webCallingService, taskDataMockVoice, { wrapUpProps: null });

// Mock the fetchIVRTranscript method to return empty array (simulating S3 failure)
mockIvrTranscriptService.fetchIVRTranscript.mockResolvedValue([]);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This won't handle the S3 api call. We will need to mock throw error from API invocation of the transcript path

);
});

it('should handle multiple transcripts', async () => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's change the description as we are not handling multiple transcripts here. Instead, we are having single transcript with data containing multiple conversation interaction.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
validated If the pull request is validated for automation.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants