diff --git a/.gitattributes b/.gitattributes
index 4689f6b54..2b1ede846 100644
--- a/.gitattributes
+++ b/.gitattributes
@@ -6,6 +6,14 @@
**/wwwroot/**/*.js text eol=lf
**/wwwroot/**/*.css text eol=lf
+# Shell scripts must keep LF endings. A CRLF checkout turns a script's shebang into `#!/bin/sh\r`, and a
+# container that copies the script then fails to start because no interpreter by that name exists.
+*.sh text eol=lf
+
+# Vendored specification cassettes are verified against the SHA-256 of the upstream file recorded in their
+# manifest. Line ending normalization would rewrite the bytes and break that check on Windows checkouts.
+tests/CrestApps.OrchardCore.Tests/Telephony/Cassettes/** -text
+
# Ensure binary files are never treated as text.
*.webp binary
*.png binary
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index cfe7edc9c..2c73e7495 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -3,9 +3,17 @@ updates:
- package-ecosystem: "nuget"
directory: "/"
schedule:
- interval: "daily"
- # Disable version update PRs; only Dependabot security updates create PRs.
- open-pull-requests-limit: 0
+ interval: "weekly"
+ # Version update PRs are enabled so patched versions arrive before an advisory is published.
+ # NuGetAudit fails the build on a known advisory, so a dependency left to drift becomes a hard
+ # stop rather than a warning. OrchardCore packages stay pinned via the ignore list below.
+ open-pull-requests-limit: 10
+ groups:
+ # One PR per weekly run for routine bumps keeps review load proportional to risk.
+ non-major-dependencies:
+ update-types:
+ - "minor"
+ - "patch"
ignore:
# OrchardCore packages are pinned; never auto-update them.
- dependency-name: "OrchardCore.*"
@@ -19,11 +27,22 @@ updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
- interval: "daily"
- open-pull-requests-limit: 0
+ interval: "weekly"
+ open-pull-requests-limit: 5
+ groups:
+ non-major-dependencies:
+ update-types:
+ - "minor"
+ - "patch"
- package-ecosystem: "github-actions"
directory: "/"
schedule:
- interval: "daily"
- open-pull-requests-limit: 0
+ interval: "weekly"
+ # Action versions are part of the supply chain: a stale action is an unpatched dependency that runs
+ # with repository credentials.
+ open-pull-requests-limit: 5
+ groups:
+ actions:
+ patterns:
+ - "*"
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 15a391bd5..71da481ca 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -35,7 +35,7 @@ jobs:
config-file: ./.github/codeql/codeql-config.yml
- name: Build solution
- run: dotnet build CrestApps.OrchardCore.slnx -c Release /p:NuGetAudit=false
+ run: dotnet build CrestApps.OrchardCore.slnx -c Release
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v3
diff --git a/.github/workflows/main_ci.yml b/.github/workflows/main_ci.yml
index d9c87bf3f..36ccd3dc1 100644
--- a/.github/workflows/main_ci.yml
+++ b/.github/workflows/main_ci.yml
@@ -27,6 +27,9 @@ jobs:
os: [ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v6
+ with:
+ # The migration additive-only gate checks never-released justifications against stable release tags.
+ fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: "15"
@@ -37,7 +40,7 @@ jobs:
- name: Build
# See pr_ci.yml for the reason why we disable NuGet audit warnings.
run: |
- dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false
+ dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true
- name: Unit Tests
run: |
dotnet test -c Release --no-build ./tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj
diff --git a/.github/workflows/pr_ci.yml b/.github/workflows/pr_ci.yml
index 2d17eff3f..e87721382 100644
--- a/.github/workflows/pr_ci.yml
+++ b/.github/workflows/pr_ci.yml
@@ -20,6 +20,9 @@ jobs:
name: Build & Test
steps:
- uses: actions/checkout@v6
+ with:
+ # The migration additive-only gate checks never-released justifications against stable release tags.
+ fetch-depth: 0
- uses: actions/setup-node@v6
with:
node-version: "15"
@@ -33,7 +36,7 @@ jobs:
# treat warnings as errors could break anytime, without us changing the code. This prevents that. Treating them as
# warnings and other better approaches don't work, see https://github.com/OrchardCMS/OrchardCore/pull/16317.
run: |
- dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false
+ dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true
- name: Unit Tests
run: |
dotnet test -c Release --no-build ./tests/CrestApps.OrchardCore.Tests/CrestApps.OrchardCore.Tests.csproj
diff --git a/.github/workflows/preview_ci.yml b/.github/workflows/preview_ci.yml
index 381acd880..6c3322640 100644
--- a/.github/workflows/preview_ci.yml
+++ b/.github/workflows/preview_ci.yml
@@ -16,6 +16,9 @@ jobs:
name: Build, Test, Deploy
steps:
- uses: actions/checkout@v6
+ with:
+ # The migration additive-only gate checks never-released justifications against stable release tags.
+ fetch-depth: 0
- name: Check if should publish
id: check-publish
shell: pwsh
@@ -40,7 +43,7 @@ jobs:
if: steps.check-publish.outputs.should-publish == 'true'
# See pr_ci.yml for the reason why we disable NuGet audit warnings.
run: |
- dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true /p:NuGetAudit=false
+ dotnet build -c Release -warnaserror /p:TreatWarningsAsErrors=true /p:RunAnalyzers=true
- name: Unit Tests
if: steps.check-publish.outputs.should-publish == 'true'
run: |
diff --git a/.github/workflows/validate_docs.yml b/.github/workflows/validate_docs.yml
index c86f89c2d..fe9fb961d 100644
--- a/.github/workflows/validate_docs.yml
+++ b/.github/workflows/validate_docs.yml
@@ -3,7 +3,9 @@ name: Validate Documentation
on:
pull_request:
paths:
- - 'src/CrestApps.Docs/**'
+ - '.github/workflows/validate_docs.yml'
+ - 'src/**'
+ - 'tests/**'
permissions:
contents: read
@@ -29,7 +31,10 @@ jobs:
run: npm ci
- name: Build and validate links
+ # `set -o pipefail` is required: without it the pipeline exit status is `tee`'s, so a failing
+ # docusaurus build reported success and only broken links were ever caught.
run: |
+ set -o pipefail
npx docusaurus build 2>&1 | tee build-output.txt
if grep -qi "broken link" build-output.txt; then
echo ""
diff --git a/.github/workflows/validate_prompts.yml b/.github/workflows/validate_prompts.yml
index 985ff7fca..0fca74dd9 100644
--- a/.github/workflows/validate_prompts.yml
+++ b/.github/workflows/validate_prompts.yml
@@ -24,7 +24,7 @@ jobs:
10.0.x
- name: Build prompt validation tool
run: |
- dotnet build -c Release src/Common/CrestApps.Core.Templates/CrestApps.Core.Templates.csproj /p:NuGetAudit=false
+ dotnet build -c Release src/Common/CrestApps.Core.Templates/CrestApps.Core.Templates.csproj
- name: Validate template file structure
shell: bash
run: |
diff --git a/.gitignore b/.gitignore
index 5aa6a12e0..588bba933 100644
--- a/.gitignore
+++ b/.gitignore
@@ -472,3 +472,9 @@ src/docs/node_modules/
src/docs/build/
src/docs/.docusaurus/
src/docs/.cache-loader/
+
+# Public API approval output written when a recorded surface no longer matches.
+*.received.txt
+
+# Playwright MCP planning scaffolding (not part of the shipped product).
+.playwright-mcp/
diff --git a/Directory.Build.props b/Directory.Build.props
index 3c3d902fb..a019da691 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -17,9 +17,37 @@
$(DefaultTargetFramework)
+
+
+ true
+
+
+
+
+ true
+ all
+ low
+ $(WarningsAsErrors);NU1901;NU1902;NU1903;NU1904
+
+
- $(CommonTargetFrameworks)
+ $(CommonTargetFrameworks)
+ $(CommonTargetFrameworks)enableMike AlhayekCrestApps
diff --git a/Directory.Packages.props b/Directory.Packages.props
index 11d3d2969..1367f7302 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -24,6 +24,7 @@
+
@@ -34,6 +35,7 @@
+
@@ -72,6 +74,7 @@
+
@@ -136,6 +139,7 @@
+
@@ -149,8 +153,10 @@
+
+
diff --git a/gulpfile.js b/gulpfile.js
index 496688fc4..f5ade377f 100644
--- a/gulpfile.js
+++ b/gulpfile.js
@@ -99,7 +99,7 @@ gulp.task('default', gulp.series(['build']));
*/
function getAssetGroups() {
- var assetManifestPaths = glob.sync("./src/{Modules,Resources,Themes}/*/Assets.json", {});
+ var assetManifestPaths = glob.sync("./src/{Modules,Resources,Startup,Themes}/*/Assets.json", {});
var assetGroups = [];
assetManifestPaths.forEach(function (assetManifestPath) {
var assetManifest = require("./" + assetManifestPath);
diff --git a/package-lock.json b/package-lock.json
index 3e7b3bc8e..76702df09 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,7 +8,8 @@
"hasInstallScript": true,
"dependencies": {
"chart.js": "^4.5.1",
- "extendable-media-recorder": "^9.2.36"
+ "extendable-media-recorder": "^9.2.36",
+ "sip.js": "0.21.2"
},
"devDependencies": {
"@babel/core": "^7.29.7",
@@ -6999,6 +7000,15 @@
"node": ">= 0.4"
}
},
+ "node_modules/sip.js": {
+ "version": "0.21.2",
+ "resolved": "https://registry.npmjs.org/sip.js/-/sip.js-0.21.2.tgz",
+ "integrity": "sha512-tSqTcIgrOd2IhP/rd70JablvAp+fSfLSxO4hGNY6LkWRY1SKygTO7OtJEV/BQb8oIxtMRx0LE7nUF2MaqGbFzA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10.0"
+ }
+ },
"node_modules/source-map": {
"version": "0.7.6",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
diff --git a/package.json b/package.json
index da033f93e..ac9151826 100644
--- a/package.json
+++ b/package.json
@@ -47,7 +47,8 @@
},
"dependencies": {
"chart.js": "^4.5.1",
- "extendable-media-recorder": "^9.2.36"
+ "extendable-media-recorder": "^9.2.36",
+ "sip.js": "0.21.2"
},
"overrides": {
"brace-expansion": "^5.0.5",
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallContextProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallContextProvider.cs
new file mode 100644
index 000000000..a3eff8524
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallContextProvider.cs
@@ -0,0 +1,20 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Contributes contextual cards shown alongside a ringing inbound call in the soft-phone
+/// incoming-call modal. Other modules implement this contract to surface related records, such as a
+/// Contact Center showing customers matched by the caller's phone number, without the Telephony
+/// module taking a dependency on them.
+///
+public interface IIncomingCallContextProvider
+{
+ ///
+ /// Contributes cards for the ringing inbound call described by the supplied context.
+ ///
+ /// The contribution context that carries the call and accepts the cards.
+ /// The token to monitor for cancellation requests.
+ /// A task that represents the asynchronous operation.
+ Task ContributeAsync(IncomingCallContributionContext context, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallDispatcher.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallDispatcher.cs
new file mode 100644
index 000000000..20b1c3859
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IIncomingCallDispatcher.cs
@@ -0,0 +1,20 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Pushes a ringing inbound call to a specific user's connected soft phone. The dispatcher gathers
+/// the contextual cards from the registered instances and
+/// raises the incoming-call modal on every soft-phone connection the user currently has open.
+///
+public interface IIncomingCallDispatcher
+{
+ ///
+ /// Offers the ringing inbound call to the specified user's soft phone.
+ ///
+ /// The identifier of the user the call is offered to.
+ /// The ringing inbound call.
+ /// The token to monitor for cancellation requests.
+ /// A task that represents the asynchronous operation.
+ Task DispatchAsync(string userId, TelephonyCall call, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IProviderIdentityProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IProviderIdentityProvider.cs
new file mode 100644
index 000000000..6950d4ad1
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IProviderIdentityProvider.cs
@@ -0,0 +1,17 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Contributes canonical provider identities so that voice ingress can resolve provider aliases to a
+/// single stable technical name without referencing provider implementation assemblies. Provider modules
+/// implement this contract to register their canonical name and any alternate runtime names.
+///
+public interface IProviderIdentityProvider
+{
+ ///
+ /// Gets the canonical provider identities contributed by the implementing provider module.
+ ///
+ /// The canonical provider identities and their aliases.
+ IEnumerable GetIdentities();
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IRecordingMediaStore.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IRecordingMediaStore.cs
new file mode 100644
index 000000000..68cded861
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/IRecordingMediaStore.cs
@@ -0,0 +1,46 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Persists completed conversation recordings into a durable media store, encrypting them at rest. The store
+/// is provider-neutral and pluggable: any voice provider can ingest recordings through the same contract, and
+/// concrete backends (local encrypted files, cloud blob storage, and so on) can be swapped without changing
+/// callers. Recording bytes are never stored inside the Contact Center orchestration data; the orchestration
+/// layer keeps only the opaque storage key that this store maps back to the encrypted media.
+///
+public interface IRecordingMediaStore
+{
+ ///
+ /// Encrypts and stores the supplied recording bytes at rest, keyed by the request's deterministic storage
+ /// key. The operation is idempotent for a given storage key: re-storing the same recording overwrites the
+ /// previously persisted bytes rather than creating a duplicate.
+ ///
+ /// The recording bytes together with the deterministic key and correlation metadata.
+ /// The token to monitor for cancellation requests.
+ /// The opaque storage reference that addresses the stored recording for later reads or deletion.
+ Task StoreAsync(RecordingMediaWriteRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Opens a readable, decrypted stream over a previously stored recording.
+ ///
+ /// The storage reference returned when the recording was stored.
+ /// The token to monitor for cancellation requests.
+ ///
+ /// A readable stream of the decrypted recording bytes, or when no recording is stored
+ /// for the supplied reference.
+ ///
+ Task OpenReadAsync(string storageReference, CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes a stored recording. The operation is idempotent: deleting a recording that is already absent is
+ /// treated as a successful no-op so a right-to-erasure request can be safely retried.
+ ///
+ /// The storage reference returned when the recording was stored.
+ /// The token to monitor for cancellation requests.
+ ///
+ /// when the store confirms that no media remains for the supplied reference; otherwise,
+ /// when deletion could not be confirmed.
+ ///
+ Task DeleteAsync(string storageReference, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneCredentialRevoker.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneCredentialRevoker.cs
new file mode 100644
index 000000000..5d4915d5b
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneCredentialRevoker.cs
@@ -0,0 +1,26 @@
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Revokes the browser soft-phone credentials owned by an authenticated user. Providers that mint
+/// short-lived, server-owned browser SIP credentials implement this so the credentials can be torn
+/// down on sign-out or session termination instead of lingering until natural expiry.
+///
+public interface ISoftPhoneCredentialRevoker
+{
+ ///
+ /// Gets the technical provider name handled by this revoker.
+ ///
+ string ProviderName { get; }
+
+ ///
+ /// Revokes every live browser credential owned by the specified authenticated user.
+ ///
+ /// The authenticated user identifier whose credentials must be revoked.
+ /// The reason recorded for the revocation.
+ /// The token to monitor for cancellation requests.
+ /// The number of credentials revoked for the user.
+ Task RevokeForUserAsync(
+ string userId,
+ string reason,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneRegistrationConfigContributor.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneRegistrationConfigContributor.cs
new file mode 100644
index 000000000..f5b5658f0
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISoftPhoneRegistrationConfigContributor.cs
@@ -0,0 +1,24 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Builds provider-specific browser soft-phone registration configuration for the active tenant.
+///
+public interface ISoftPhoneRegistrationConfigContributor
+{
+ ///
+ /// Gets the technical provider name handled by this contributor.
+ ///
+ string ProviderName { get; }
+
+ ///
+ /// Builds a short-lived browser registration configuration for the current soft-phone session.
+ ///
+ /// The registration request context.
+ /// The cancellation token.
+ /// The registration configuration, or when the provider is unavailable.
+ Task BuildAsync(
+ SoftPhoneRegistrationConfigContext context,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISupportsTenantMediaPurge.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISupportsTenantMediaPurge.cs
new file mode 100644
index 000000000..f30904a67
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ISupportsTenantMediaPurge.cs
@@ -0,0 +1,14 @@
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Provides tenant-wide recording media cleanup for an .
+///
+public interface ISupportsTenantMediaPurge
+{
+ ///
+ /// Deletes every recording owned by the current tenant.
+ ///
+ /// The token to monitor for cancellation requests.
+ /// when all tenant media was removed; otherwise, .
+ Task TryPurgeAllAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAttendedTransferProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAttendedTransferProvider.cs
new file mode 100644
index 000000000..ed6eb0a16
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAttendedTransferProvider.cs
@@ -0,0 +1,18 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes the attended (warm) transfer operation a telephony provider supports, where the transferring
+/// party consults the destination before the call is released to it.
+///
+public interface ITelephonyAttendedTransferProvider
+{
+ ///
+ /// Starts an attended transfer of an active call to another destination.
+ ///
+ /// The transfer request describing the destination.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task StartAttendedTransferAsync(TransferRequest request, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAudioProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAudioProvider.cs
new file mode 100644
index 000000000..ba54f9905
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyAudioProvider.cs
@@ -0,0 +1,24 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Describes how a telephony provider delivers live call audio to the agent.
+///
+public interface ITelephonyAudioProvider
+{
+ ///
+ /// Gets the audio delivery modes supported by the provider.
+ ///
+ TelephonyAudioCapabilities AudioCapabilities { get; }
+
+ ///
+ /// Gets the provider-configured audio delivery mode when more than one mode is supported.
+ ///
+ TelephonyAudioMode ConfiguredAudioMode { get; }
+
+ ///
+ /// Gets the browser media adapter name registered by the provider when browser audio is supported.
+ ///
+ string BrowserMediaAdapterName { get; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallControlProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallControlProvider.cs
new file mode 100644
index 000000000..7b6c76452
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallControlProvider.cs
@@ -0,0 +1,26 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes the call-control operations a telephony provider supports. A provider that cannot place or
+/// end calls simply does not implement this contract, and the soft phone refuses those operations.
+///
+public interface ITelephonyCallControlProvider
+{
+ ///
+ /// Places an outbound call.
+ ///
+ /// The dial request describing the destination and caller identifier.
+ /// The cancellation token.
+ /// A describing the placed call or the failure reason.
+ Task DialAsync(DialRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Ends an active call.
+ ///
+ /// A reference to the call to end.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task HangupAsync(CallReference call, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallStateProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallStateProvider.cs
new file mode 100644
index 000000000..30a093d79
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCallStateProvider.cs
@@ -0,0 +1,18 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Exposes provider-truth call-state lookups for telephony providers that can query the current server
+/// state of a live call.
+///
+public interface ITelephonyCallStateProvider
+{
+ ///
+ /// Queries the provider for the current state of the specified call.
+ ///
+ /// The provider call identifier.
+ /// The token to monitor for cancellation requests.
+ /// The lookup result describing whether the call was found and, when available, its current state.
+ Task GetCallStateAsync(string callId, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyClient.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyClient.cs
index 1350d8e93..52c4053c2 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyClient.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyClient.cs
@@ -19,8 +19,9 @@ public interface ITelephonyClient
/// Notifies the client that an inbound call is ringing.
///
/// The inbound call.
+ /// The contextual cards contributed for the call, such as matched customers.
/// A task that represents the asynchronous operation.
- Task IncomingCall(TelephonyCall call);
+ Task IncomingCall(TelephonyCall call, IncomingCallContext context);
///
/// Notifies the client that the provider issued new connection credentials.
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCommandExecutor.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCommandExecutor.cs
new file mode 100644
index 000000000..a1c1b0585
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyCommandExecutor.cs
@@ -0,0 +1,18 @@
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes a telephony provider mutation with a bounded server-owned cancellation token.
+///
+public interface ITelephonyCommandExecutor
+{
+ ///
+ /// Executes a telephony provider mutation independently of caller or connection cancellation.
+ ///
+ /// The operation result type.
+ /// The operation that receives the bounded server-owned cancellation token.
+ /// The operation result.
+ /// Thrown when the operation is not confirmed before the server-owned deadline expires.
+ /// Thrown when the host is shutting down before the operation is dispatched; the provider is never contacted, so the command is guaranteed not to have been applied.
+ /// Thrown when the operation is interrupted after dispatch because the host is shutting down; the provider outcome is indeterminate.
+ Task ExecuteAsync(Func> operation);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyConferenceProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyConferenceProvider.cs
new file mode 100644
index 000000000..e51d5e1a7
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyConferenceProvider.cs
@@ -0,0 +1,17 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes the conference operations a telephony provider supports.
+///
+public interface ITelephonyConferenceProvider
+{
+ ///
+ /// Merges two active calls into a single conference.
+ ///
+ /// The merge request describing the calls to join.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task MergeAsync(MergeRequest request, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDirectoryProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDirectoryProvider.cs
new file mode 100644
index 000000000..c424989cf
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDirectoryProvider.cs
@@ -0,0 +1,16 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Defines optional provider directory lookup support for transfer destinations.
+///
+public interface ITelephonyDirectoryProvider
+{
+ ///
+ /// Gets directory entries that can be used as call-transfer destinations.
+ ///
+ /// The cancellation token.
+ /// The provider directory lookup result.
+ Task GetDirectoryAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDtmfProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDtmfProvider.cs
new file mode 100644
index 000000000..05144cb5b
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyDtmfProvider.cs
@@ -0,0 +1,17 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes the DTMF operations a telephony provider supports.
+///
+public interface ITelephonyDtmfProvider
+{
+ ///
+ /// Sends DTMF digits to an active call.
+ ///
+ /// The request describing the call and the digits to send.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task SendDigitsAsync(SendDigitsRequest request, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyHoldProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyHoldProvider.cs
new file mode 100644
index 000000000..5b8507e5f
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyHoldProvider.cs
@@ -0,0 +1,25 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes the hold and resume operations a telephony provider supports.
+///
+public interface ITelephonyHoldProvider
+{
+ ///
+ /// Places an active call on hold.
+ ///
+ /// A reference to the call to place on hold.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task HoldAsync(CallReference call, CancellationToken cancellationToken = default);
+
+ ///
+ /// Resumes a call that is currently on hold.
+ ///
+ /// A reference to the call to resume.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task ResumeAsync(CallReference call, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInboundCallProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInboundCallProvider.cs
new file mode 100644
index 000000000..62dfef0e3
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInboundCallProvider.cs
@@ -0,0 +1,26 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes the inbound-call operations a telephony provider supports. A provider that only places
+/// outbound calls does not implement this contract.
+///
+public interface ITelephonyInboundCallProvider
+{
+ ///
+ /// Answers a ringing inbound call.
+ ///
+ /// A reference to the inbound call to answer.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task AnswerAsync(CallReference call, CancellationToken cancellationToken = default);
+
+ ///
+ /// Rejects a ringing inbound call.
+ ///
+ /// A reference to the inbound call to reject.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task RejectAsync(CallReference call, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs
index 6ceec1403..fa7fe0e99 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionStore.cs
@@ -17,13 +17,59 @@ public interface ITelephonyInteractionStore
///
/// Updates an existing interaction. The write is guarded by an optimistic-concurrency check, so a
- /// caller that mutated a stale copy fails instead of silently discarding a concurrent update.
+ /// caller that mutated a stale copy fails loudly instead of silently discarding a concurrent update.
///
/// The interaction to update.
/// The cancellation token.
/// A task that represents the asynchronous operation.
Task UpdateAsync(TelephonyInteraction interaction, CancellationToken cancellationToken = default);
+ ///
+ /// Applies a mutation to the interaction with the given identifier inside a dedicated session, re-reading
+ /// and reapplying the mutation whenever a concurrent writer commits first.
+ ///
+ /// The interaction identifier.
+ ///
+ /// The mutation to apply to the freshly read interaction. Returning abandons the
+ /// attempt without writing, which lets a caller decline based on state it can only observe after the read.
+ ///
+ /// The cancellation token.
+ ///
+ /// The interaction as it was read and mutated, or when no interaction matches.
+ ///
+ Task UpdateByIdAsync(
+ string interactionId,
+ Func mutate,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Applies a mutation to the interaction for the given provider and provider call identifier inside a
+ /// dedicated session, re-reading and reapplying the mutation whenever a concurrent writer commits first.
+ ///
+ /// The technical provider name.
+ /// The provider-specific call identifier.
+ ///
+ /// The mutation to apply to the freshly read interaction. Returning abandons the
+ /// attempt without writing, which lets a caller decline based on state it can only observe after the read.
+ ///
+ /// The cancellation token.
+ ///
+ /// The interaction as it was read and mutated, or when no interaction matches.
+ ///
+ Task UpdateByProviderCallIdAsync(
+ string providerName,
+ string callId,
+ Func mutate,
+ CancellationToken cancellationToken = default);
+
+ ///
+ /// Deletes an interaction that no longer exists at the telephony provider.
+ ///
+ /// The interaction to delete.
+ /// The cancellation token.
+ /// A task that represents the asynchronous operation.
+ Task DeleteAsync(TelephonyInteraction interaction, CancellationToken cancellationToken = default);
+
///
/// Finds the interaction for the given user and provider call identifier.
///
@@ -33,6 +79,49 @@ public interface ITelephonyInteractionStore
/// The interaction, or when none matches.
Task FindByCallIdAsync(string userId, string callId, CancellationToken cancellationToken = default);
+ ///
+ /// Finds the interaction for the given provider and provider call identifier, regardless of the
+ /// current user's connection state.
+ ///
+ /// The technical provider name.
+ /// The provider-specific call identifier.
+ /// The cancellation token.
+ /// The interaction, or when none matches.
+ Task FindByProviderCallIdAsync(string providerName, string callId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Finds the most recent in-progress interaction for the given user.
+ ///
+ /// The user identifier.
+ /// The cancellation token.
+ /// The active interaction, or when none matches.
+ Task FindActiveByUserAsync(string userId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists all in-progress interactions for the given user.
+ ///
+ /// The user identifier.
+ /// The cancellation token.
+ /// The user's active interactions, newest first.
+ Task> ListActiveByUserAsync(string userId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists in-progress interactions that can be reconciled against their providers, oldest first and bounded for reconciliation sweeps.
+ ///
+ /// The maximum number of interactions to return.
+ /// The cancellation token.
+ /// The oldest active interactions bounded by .
+ Task> ListActiveAsync(int maxCount, CancellationToken cancellationToken = default);
+
+ ///
+ /// Lists in-progress interactions for the specified provider, oldest first and bounded for reconciliation sweeps.
+ ///
+ /// The technical provider name.
+ /// The maximum number of interactions to return.
+ /// The cancellation token.
+ /// The oldest active interactions for the provider bounded by .
+ Task> ListActiveAsync(string providerName, int maxCount, CancellationToken cancellationToken = default);
+
///
/// Gets the most recent interactions for the given user, newest first.
///
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionSynchronizationService.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionSynchronizationService.cs
new file mode 100644
index 000000000..fc829442b
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyInteractionSynchronizationService.cs
@@ -0,0 +1,40 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Reconciles locally persisted telephony interactions with provider-authoritative call state.
+///
+public interface ITelephonyInteractionSynchronizationService
+{
+ ///
+ /// Gets the current provider-authoritative call for a user.
+ ///
+ /// The user identifier.
+ /// The cancellation token.
+ /// The provider call lookup result.
+ Task GetActiveCallAsync(string userId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Gets every provider-authoritative active call for a user.
+ ///
+ /// The user identifier.
+ /// The cancellation token.
+ /// The provider call-list lookup result.
+ Task GetActiveCallsAsync(string userId, CancellationToken cancellationToken = default);
+
+ ///
+ /// Reconciles all active telephony interactions.
+ ///
+ /// The cancellation token.
+ /// The number of interactions whose persisted state changed.
+ Task ReconcileActiveInteractionsAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Reconciles active telephony interactions for one provider.
+ ///
+ /// The technical provider name.
+ /// The cancellation token.
+ /// The number of interactions whose persisted state changed.
+ Task ReconcileProviderInteractionsAsync(string providerName, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyMuteProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyMuteProvider.cs
new file mode 100644
index 000000000..5a1da952e
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyMuteProvider.cs
@@ -0,0 +1,25 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes the mute and unmute operations a telephony provider supports.
+///
+public interface ITelephonyMuteProvider
+{
+ ///
+ /// Mutes the local audio of an active call.
+ ///
+ /// A reference to the call to mute.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task MuteAsync(CallReference call, CancellationToken cancellationToken = default);
+
+ ///
+ /// Unmutes the local audio of an active call.
+ ///
+ /// A reference to the call to unmute.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task UnmuteAsync(CallReference call, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs
index 95cc4fda1..fad00b5a2 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyProvider.cs
@@ -4,8 +4,9 @@
namespace CrestApps.OrchardCore.Telephony;
///
-/// Defines the provider-agnostic operations a telephony provider must implement so the soft phone
-/// can control calls through any configured provider.
+/// Identifies a telephony provider and the capabilities it advertises. Executable operations live on the
+/// separate capability contracts a provider chooses to implement, so a provider is never obliged to answer
+/// for an operation it cannot perform.
///
public interface ITelephonyProvider
{
@@ -15,102 +16,8 @@ public interface ITelephonyProvider
LocalizedString Name { get; }
///
- /// Gets the set of operations the provider supports.
+ /// Gets the set of operations the provider supports. Advertising a capability is not sufficient on its
+ /// own: the provider must also implement the matching executable contract or the operation fails closed.
///
TelephonyCapabilities Capabilities { get; }
-
- ///
- /// Places an outbound call.
- ///
- /// The dial request describing the destination and caller identifier.
- /// The cancellation token.
- /// A describing the placed call or the failure reason.
- Task DialAsync(DialRequest request, CancellationToken cancellationToken = default);
-
- ///
- /// Ends an active call.
- ///
- /// A reference to the call to end.
- /// The cancellation token.
- /// A describing the outcome.
- Task HangupAsync(CallReference call, CancellationToken cancellationToken = default);
-
- ///
- /// Places an active call on hold.
- ///
- /// A reference to the call to place on hold.
- /// The cancellation token.
- /// A describing the outcome.
- Task HoldAsync(CallReference call, CancellationToken cancellationToken = default);
-
- ///
- /// Resumes a call that is currently on hold.
- ///
- /// A reference to the call to resume.
- /// The cancellation token.
- /// A describing the outcome.
- Task ResumeAsync(CallReference call, CancellationToken cancellationToken = default);
-
- ///
- /// Mutes the local audio of an active call.
- ///
- /// A reference to the call to mute.
- /// The cancellation token.
- /// A describing the outcome.
- Task MuteAsync(CallReference call, CancellationToken cancellationToken = default);
-
- ///
- /// Unmutes the local audio of an active call.
- ///
- /// A reference to the call to unmute.
- /// The cancellation token.
- /// A describing the outcome.
- Task UnmuteAsync(CallReference call, CancellationToken cancellationToken = default);
-
- ///
- /// Transfers an active call to another destination.
- ///
- /// The transfer request describing the destination and transfer mode.
- /// The cancellation token.
- /// A describing the outcome.
- Task TransferAsync(TransferRequest request, CancellationToken cancellationToken = default);
-
- ///
- /// Merges two active calls into a single conference.
- ///
- /// The merge request describing the calls to join.
- /// The cancellation token.
- /// A describing the outcome.
- Task MergeAsync(MergeRequest request, CancellationToken cancellationToken = default);
-
- ///
- /// Sends DTMF digits to an active call.
- ///
- /// The request describing the call and the digits to send.
- /// The cancellation token.
- /// A describing the outcome.
- Task SendDigitsAsync(SendDigitsRequest request, CancellationToken cancellationToken = default);
-
- ///
- /// Answers a ringing inbound call.
- ///
- /// A reference to the inbound call to answer.
- /// The cancellation token.
- /// A describing the outcome.
- Task AnswerAsync(CallReference call, CancellationToken cancellationToken = default);
-
- ///
- /// Rejects a ringing inbound call.
- ///
- /// A reference to the inbound call to reject.
- /// The cancellation token.
- /// A describing the outcome.
- Task RejectAsync(CallReference call, CancellationToken cancellationToken = default);
-
- ///
- /// Issues the bootstrap configuration a soft phone client needs to connect to the provider.
- ///
- /// The cancellation token.
- /// The for the provider.
- Task GetClientCredentialsAsync(CancellationToken cancellationToken = default);
}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs
index a59b60d8c..1d2107abd 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyService.cs
@@ -96,6 +96,14 @@ public interface ITelephonyService
/// A describing the outcome.
Task RejectAsync(CallReference call, CancellationToken cancellationToken = default);
+ ///
+ /// Sends a ringing inbound call to voicemail using the default provider.
+ ///
+ /// A reference to the inbound call to send to voicemail.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default);
+
///
/// Issues the bootstrap configuration a soft phone client needs to connect to the default provider.
///
@@ -103,6 +111,13 @@ public interface ITelephonyService
/// The for the default provider.
Task GetClientCredentialsAsync(CancellationToken cancellationToken = default);
+ ///
+ /// Gets transfer destinations from the configured provider directory.
+ ///
+ /// The cancellation token.
+ /// The provider directory lookup result.
+ Task GetDirectoryAsync(CancellationToken cancellationToken = default);
+
///
/// Gets the capabilities of the configured default provider.
///
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonySoftPhoneCredentialsProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonySoftPhoneCredentialsProvider.cs
new file mode 100644
index 000000000..87d09f35f
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonySoftPhoneCredentialsProvider.cs
@@ -0,0 +1,17 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Issues the bootstrap configuration a soft phone client needs to connect to a telephony provider. A
+/// provider that is driven only from the server, with no browser client, does not implement this contract.
+///
+public interface ITelephonySoftPhoneCredentialsProvider
+{
+ ///
+ /// Issues the bootstrap configuration a soft phone client needs to connect to the provider.
+ ///
+ /// The cancellation token.
+ /// The for the provider.
+ Task GetClientCredentialsAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyTransferProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyTransferProvider.cs
new file mode 100644
index 000000000..7ef6321d3
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyTransferProvider.cs
@@ -0,0 +1,18 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes the blind transfer operation a telephony provider supports. Attended transfer is a separate
+/// contract, because a provider can release a call to a destination without being able to consult it first.
+///
+public interface ITelephonyTransferProvider
+{
+ ///
+ /// Transfers an active call to another destination without consulting the destination first.
+ ///
+ /// The transfer request describing the destination.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task TransferAsync(TransferRequest request, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyVoicemailProvider.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyVoicemailProvider.cs
new file mode 100644
index 000000000..7d3c3312a
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/ITelephonyVoicemailProvider.cs
@@ -0,0 +1,17 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Executes the voicemail operations a telephony provider supports.
+///
+public interface ITelephonyVoicemailProvider
+{
+ ///
+ /// Sends a ringing inbound call to voicemail.
+ ///
+ /// A reference to the inbound call to send to voicemail.
+ /// The cancellation token.
+ /// A describing the outcome.
+ Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/AnswerClassification.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/AnswerClassification.cs
new file mode 100644
index 000000000..de9ac3aff
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/AnswerClassification.cs
@@ -0,0 +1,30 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Identifies the provider-neutral outcome of automated answer detection for an outbound voice call.
+/// AMD (Answering Machine Detection) classifies how or whether the remote party answered so the
+/// dialer, compliance, and analytics layers can take the correct follow-up action regardless of
+/// the telephony provider that reported it.
+///
+public enum AnswerClassification
+{
+ ///
+ /// A live person answered the call.
+ ///
+ Human,
+
+ ///
+ /// An answering machine or voicemail greeting was detected instead of a live person.
+ ///
+ Machine,
+
+ ///
+ /// A fax machine tone was detected.
+ ///
+ Fax,
+
+ ///
+ /// Detection completed but the outcome could not be determined with sufficient confidence.
+ ///
+ Unknown,
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/CallReference.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/CallReference.cs
index 7823bd1f1..c5779c1ca 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/CallReference.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/CallReference.cs
@@ -9,4 +9,11 @@ public sealed class CallReference
/// Gets or sets the provider-specific identifier of the call.
///
public string CallId { get; set; }
+
+ ///
+ /// Gets or sets optional provider-neutral metadata associated with the call action.
+ /// Providers can inspect this bag for routing or policy hints without requiring new shared
+ /// interface properties for each integration-specific scenario.
+ ///
+ public IDictionary Metadata { get; set; }
}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/HangupCause.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/HangupCause.cs
new file mode 100644
index 000000000..2f5037e4e
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/HangupCause.cs
@@ -0,0 +1,60 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Identifies the provider-neutral reason a voice call ended. Providers report their own release
+/// causes — Q.850 cause codes, textual release reasons, or vendor-specific tokens — which are
+/// normalized into these values so outbound compliance reporting, abandon analytics, and retry
+/// policy can reason about how a call ended independently of the provider that ended it.
+///
+public enum HangupCause
+{
+ ///
+ /// The provider ended the call without reporting any release cause. This value exists so an
+ /// unreported cause is recorded honestly rather than being silently reported as a normal
+ /// clearing; it must never be produced when the provider did report a cause.
+ ///
+ Unknown = 0,
+
+ ///
+ /// The call was answered and then released normally by one of the parties.
+ ///
+ NormalClearing = 1,
+
+ ///
+ /// The remote party was busy.
+ ///
+ Busy = 2,
+
+ ///
+ /// The call alerted the remote party but was never answered.
+ ///
+ NoAnswer = 3,
+
+ ///
+ /// The remote party or the network explicitly rejected the call.
+ ///
+ Rejected = 4,
+
+ ///
+ /// The call could not be completed because the network or a switch was congested, or no
+ /// circuit was available. Unlike , a congested call is normally retryable.
+ ///
+ Congestion = 5,
+
+ ///
+ /// The call failed for a reason that is not expected to succeed on retry, such as an
+ /// unallocated number, an invalid number format, or an incompatible destination.
+ ///
+ Failed = 6,
+
+ ///
+ /// The originating side abandoned the call before it was answered.
+ ///
+ Canceled = 7,
+
+ ///
+ /// The call was answered by an answering machine, voicemail greeting, or fax tone rather than
+ /// by a live person.
+ ///
+ AnsweringMachine = 8,
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCard.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCard.cs
new file mode 100644
index 000000000..8547ef02a
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCard.cs
@@ -0,0 +1,66 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Represents a record a module contributes to an incoming-call modal, such as a customer matched by
+/// the caller's phone number. Cards let other modules (for example the Contact Center) enrich the
+/// incoming-call experience with related records and shortcuts without the Telephony module taking a
+/// dependency on them.
+///
+public sealed class IncomingCallCard
+{
+ ///
+ /// Gets or sets the stable identifier of the card, unique within a single incoming-call context.
+ ///
+ public string Id { get; set; }
+
+ ///
+ /// Gets or sets the primary title of the card, such as the matched contact's display name.
+ ///
+ public string Title { get; set; }
+
+ ///
+ /// Gets or sets the optional secondary text of the card, such as the matched phone number.
+ ///
+ public string Subtitle { get; set; }
+
+ ///
+ /// Gets or sets the optional descriptive text shown under the title and subtitle.
+ ///
+ public string Description { get; set; }
+
+ ///
+ /// Gets or sets the optional CSS class of the icon shown for the card.
+ ///
+ public string Icon { get; set; }
+
+ ///
+ /// Gets or sets the optional URL the agent can open as a shortcut, such as the matched contact content item.
+ /// When set, the modal renders an answer-and-open action that answers the call and opens this URL.
+ ///
+ public string Url { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether opens in a new browser tab. Defaults to .
+ ///
+ public bool OpenInNewTab { get; set; } = true;
+
+ ///
+ /// Gets or sets the contributing source name, used for grouping and diagnostics.
+ ///
+ public string Source { get; set; }
+
+ ///
+ /// Gets or sets the sort priority of the card. Cards with a lower value are shown first.
+ ///
+ public int Priority { get; set; }
+
+ ///
+ /// Gets or sets the badges shown on the card, such as a queue name or a tag.
+ ///
+ public IList Badges { get; set; } = [];
+
+ ///
+ /// Gets or sets additional links shown for the card, such as related records or actions.
+ ///
+ public IList Links { get; set; } = [];
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCardLink.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCardLink.cs
new file mode 100644
index 000000000..aa60acaec
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallCardLink.cs
@@ -0,0 +1,29 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Represents a link a module contributes to an incoming-call card, such as a shortcut that opens a
+/// related record. Links are rendered as clickable actions next to the matched record in the
+/// incoming-call modal.
+///
+public sealed class IncomingCallCardLink
+{
+ ///
+ /// Gets or sets the visible text of the link.
+ ///
+ public string Text { get; set; }
+
+ ///
+ /// Gets or sets the URL the link navigates to.
+ ///
+ public string Url { get; set; }
+
+ ///
+ /// Gets or sets the optional CSS class of the icon shown next to the link.
+ ///
+ public string Icon { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the link opens in a new browser tab. Defaults to .
+ ///
+ public bool OpenInNewTab { get; set; } = true;
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContext.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContext.cs
new file mode 100644
index 000000000..00b137127
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContext.cs
@@ -0,0 +1,24 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Represents the contextual information shown alongside a ringing inbound call in the soft-phone
+/// incoming-call modal. Modules contribute the cards through an
+/// . The context is serialized to the soft-phone client.
+///
+public sealed class IncomingCallContext
+{
+ ///
+ /// Gets or sets the optional heading shown above the contributed cards.
+ ///
+ public string Heading { get; set; }
+
+ ///
+ /// Gets or sets the cards contributed for the incoming call, ordered for display.
+ ///
+ public IList Cards { get; set; } = [];
+
+ ///
+ /// Gets or sets additional metadata contributors can attach for the client, such as a queue name.
+ ///
+ public IDictionary Properties { get; set; } = new Dictionary();
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContributionContext.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContributionContext.cs
new file mode 100644
index 000000000..c88adfc48
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/IncomingCallContributionContext.cs
@@ -0,0 +1,50 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Carries the state an uses to contribute cards for a
+/// ringing inbound call. Providers add cards to and may share state through
+/// .
+///
+public sealed class IncomingCallContributionContext
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The ringing inbound call.
+ /// The identifier of the user the call is being offered to.
+ public IncomingCallContributionContext(TelephonyCall call, string userId)
+ {
+ Call = call;
+ UserId = userId;
+ }
+
+ ///
+ /// Gets the ringing inbound call the cards are contributed for.
+ ///
+ public TelephonyCall Call { get; }
+
+ ///
+ /// Gets the identifier of the user the call is being offered to.
+ ///
+ public string UserId { get; }
+
+ ///
+ /// Gets or sets the optional heading shown above the contributed cards.
+ ///
+ public string Heading { get; set; }
+
+ ///
+ /// Gets the cards contributed so far. Providers add their cards to this collection.
+ ///
+ public IList Cards { get; } = [];
+
+ ///
+ /// Gets the metadata contributors attach for the client, such as a queue name or offer lifecycle URLs.
+ ///
+ public IDictionary Properties { get; } = new Dictionary();
+
+ ///
+ /// Gets a mutable bag providers can use to share state while contributing cards.
+ ///
+ public IDictionary Items { get; } = new Dictionary();
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/MergeRequest.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/MergeRequest.cs
index 54983a162..7f8e9d684 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/MergeRequest.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/MergeRequest.cs
@@ -1,22 +1,29 @@
namespace CrestApps.OrchardCore.Telephony.Models;
///
-/// Represents a request to merge two active calls into a single conference.
+/// Represents a request to merge active calls into a single conference.
///
public sealed class MergeRequest
{
///
- /// Gets or sets the identifier of the primary call that hosts the conference.
+ /// Gets or sets the identifiers of the calls to merge.
///
- public string PrimaryCallId { get; set; }
+ public IReadOnlyList CallIds { get; set; } = [];
///
- /// Gets or sets the identifier of the secondary call to merge into the conference.
+ /// Gets or sets an optional name for the resulting conference.
///
- public string SecondaryCallId { get; set; }
+ public string ConferenceName { get; set; }
///
- /// Gets or sets an optional name for the resulting conference.
+ /// Gets the distinct, non-empty call identifiers to merge.
///
- public string ConferenceName { get; set; }
+ /// The call identifiers to merge.
+ public IReadOnlyList GetCallIds()
+ {
+ return (CallIds ?? [])
+ .Where(callId => !string.IsNullOrWhiteSpace(callId))
+ .Distinct(StringComparer.Ordinal)
+ .ToList();
+ }
}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderIdentity.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderIdentity.cs
new file mode 100644
index 000000000..b972d0f40
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderIdentity.cs
@@ -0,0 +1,36 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Describes the canonical technical identity of a telephony or voice provider together with the
+/// alternate names (aliases) that resolve to it. A single provider family can register multiple
+/// runtime names (for example a tenant-configured provider and a configuration-backed default
+/// provider) that must all map to one stable identity before it is used to build inbox, event, or
+/// call keys.
+///
+public sealed class ProviderIdentity
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// The stable canonical technical name of the provider.
+ /// The alternate provider names that resolve to .
+ public ProviderIdentity(string canonicalName, params string[] aliases)
+ {
+ ArgumentException.ThrowIfNullOrEmpty(canonicalName);
+
+ CanonicalName = canonicalName;
+ Aliases = aliases is null || aliases.Length == 0
+ ? []
+ : aliases;
+ }
+
+ ///
+ /// Gets the stable canonical technical name of the provider.
+ ///
+ public string CanonicalName { get; }
+
+ ///
+ /// Gets the alternate provider names that resolve to .
+ ///
+ public IReadOnlyCollection Aliases { get; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderVoiceEvent.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderVoiceEvent.cs
new file mode 100644
index 000000000..585b17fc1
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/ProviderVoiceEvent.cs
@@ -0,0 +1,199 @@
+using System.Collections;
+using System.Collections.Concurrent;
+using System.Collections.Frozen;
+using System.Collections.Immutable;
+
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Represents a provider-neutral voice event after a telephony provider or PBX webhook has been
+/// normalized. It is the single entry point through which provider call-state changes (ringing,
+/// answered, held, transferred, ended, failed) flow into every consumer projection, so each projection
+/// built on the same provider stream stays in sync regardless of the provider.
+///
+///
+/// The type is immutable. It is a public provider contract that ingestion also has to adjust — the provider
+/// identity is canonicalized and the idempotency key is scoped by it — and while it was mutable those
+/// adjustments were applied to the caller's own instance, so ingestion had to defend itself with a hand-written
+/// copy whose completeness was a separate thing to get wrong. It was got wrong once: the copy dropped
+/// , and because a session infers a cause when none is supplied, every call reported
+/// the inferred cause instead of the one the provider gave, with nothing anywhere to say the real one was lost.
+/// Adjustments are now made with , which copies every member by construction.
+///
+public sealed record ProviderVoiceEvent
+{
+ private static readonly MetadataSnapshot _emptyMetadata =
+ new(new Dictionary(StringComparer.Ordinal));
+
+ private readonly IReadOnlyDictionary _metadata = _emptyMetadata;
+
+ ///
+ /// Gets the technical name of the provider that produced the event.
+ ///
+ public string ProviderName { get; init; }
+
+ ///
+ /// Gets the provider-specific identifier of the call the event relates to.
+ ///
+ public string ProviderCallId { get; init; }
+
+ ///
+ /// Gets the provider-specific call leg identifier, when the channel has leg-level tracking.
+ ///
+ public string ProviderLegId { get; init; }
+
+ ///
+ /// Gets the normalized call state the event represents.
+ ///
+ public VoiceCallState State { get; init; }
+
+ ///
+ /// Gets the address of the calling party, when supplied.
+ ///
+ public string FromAddress { get; init; }
+
+ ///
+ /// Gets the address of the called party, when supplied.
+ ///
+ public string ToAddress { get; init; }
+
+ ///
+ /// Gets the UTC time the event occurred. When not supplied, the current time is used.
+ ///
+ public DateTime? OccurredUtc { get; init; }
+
+ ///
+ /// Gets an idempotency key that uniquely identifies this provider event so duplicate
+ /// deliveries can be de-duplicated. When set, replays of the same event are ignored.
+ ///
+ public string IdempotencyKey { get; init; }
+
+ ///
+ /// Gets an optional provider-supplied monotonic sequence number for the call stream. When
+ /// supplied, ingestion uses it as the authoritative ordering high-water mark and rejects stale or
+ /// equal-order deliveries. Providers that only supply timestamps or idempotency keys leave it
+ /// and ingestion falls back to timestamp-based ordering.
+ ///
+ public long? SequenceNumber { get; init; }
+
+ ///
+ /// Gets a value indicating whether the provider reports the call as muted.
+ /// When , the event does not change the current mute state.
+ ///
+ public bool? IsMuted { get; init; }
+
+ ///
+ /// Gets the provider-reported recording state.
+ /// When , the event does not change the current recording state.
+ ///
+ public RecordingState? RecordingState { get; init; }
+
+ ///
+ /// Gets the provider recording reference for the session, when recording is active or retained.
+ ///
+ public string RecordingReference { get; init; }
+
+ ///
+ /// Gets a value indicating whether the provider reports the call as a conference or
+ /// multi-party session. When , the event does not change the current conference flag.
+ ///
+ public bool? IsConference { get; init; }
+
+ ///
+ /// Gets the number of active participants the provider reports for the session.
+ /// When , the event does not change the current participant count.
+ ///
+ public int? ParticipantCount { get; init; }
+
+ ///
+ /// Gets the provider-neutral AMD (Answering Machine Detection) answer classification when the provider
+ /// reports AMD for this event. When , the provider did not report AMD and the event does
+ /// not change the current answer classification.
+ ///
+ public AnswerClassification? AnswerClassification { get; init; }
+
+ ///
+ /// Gets the provider-neutral reason the call ended. It is required whenever
+ /// is terminal, because a call that ended for an unrecorded reason cannot be counted in outbound
+ /// compliance reporting or abandon analytics. When the provider ends a call without reporting any
+ /// release cause, records that honestly instead of
+ /// presenting the call as a normal clearing.
+ ///
+ public HangupCause? HangupCause { get; init; }
+
+ ///
+ /// Gets additional provider metadata to retain for troubleshooting. The value is snapshotted on
+ /// assignment, so a caller that keeps its own reference to the dictionary cannot change the event
+ /// after it has been handed over.
+ ///
+ public IReadOnlyDictionary Metadata
+ {
+ get => _metadata;
+ init => _metadata = Snapshot(value);
+ }
+
+ private static MetadataSnapshot Snapshot(IReadOnlyDictionary value)
+ {
+ if (value is null || value.Count == 0)
+ {
+ return _emptyMetadata;
+ }
+
+ if (value is MetadataSnapshot snapshot)
+ {
+ return snapshot;
+ }
+
+ return new MetadataSnapshot(new Dictionary(value, ComparerOf(value)));
+ }
+
+ private static IEqualityComparer ComparerOf(IReadOnlyDictionary value)
+ {
+ // The comparer is carried over wherever the source can report one, because providers key their
+ // metadata case-insensitively and a snapshot that quietly became case-sensitive would change what
+ // consumers can find. An implementation that reports no comparer is keyed ordinally, which is the
+ // only honest choice when the source will not say how it compares its own keys.
+ return value switch
+ {
+ MetadataSnapshot snapshot => snapshot.Comparer,
+ Dictionary dictionary => dictionary.Comparer,
+ ConcurrentDictionary dictionary => dictionary.Comparer,
+ ImmutableDictionary dictionary => dictionary.KeyComparer,
+ FrozenDictionary dictionary => dictionary.Comparer,
+ _ => StringComparer.Ordinal,
+ };
+ }
+
+ ///
+ /// An immutable view over metadata that reports the comparer its keys are held under, so a snapshot
+ /// taken from another event's keeps the comparer the provider supplied instead
+ /// of silently falling back to ordinal comparison.
+ ///
+ private sealed class MetadataSnapshot : IReadOnlyDictionary
+ {
+ private readonly Dictionary _values;
+
+ public MetadataSnapshot(Dictionary values)
+ {
+ _values = values;
+ }
+
+ public IEqualityComparer Comparer => _values.Comparer;
+
+ public string this[string key] => _values[key];
+
+ public IEnumerable Keys => _values.Keys;
+
+ public IEnumerable Values => _values.Values;
+
+ public int Count => _values.Count;
+
+ public bool ContainsKey(string key) => _values.ContainsKey(key);
+
+ public bool TryGetValue(string key, out string value) => _values.TryGetValue(key, out value);
+
+ public IEnumerator> GetEnumerator() => _values.GetEnumerator();
+
+ IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
+ }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingMediaWriteRequest.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingMediaWriteRequest.cs
new file mode 100644
index 000000000..4d1469bfa
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingMediaWriteRequest.cs
@@ -0,0 +1,34 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Describes a completed conversation recording whose bytes are being ingested into a durable, encrypted
+/// media store. The request carries the opaque bytes together with the deterministic key that addresses the
+/// recording, so a store implementation can persist and later retrieve the same recording without any
+/// provider-specific knowledge.
+///
+public sealed class RecordingMediaWriteRequest
+{
+ ///
+ /// Gets or sets the deterministic, provider-neutral key that uniquely and stably addresses this recording.
+ /// The same key is used to read or delete the stored recording, so it must be derivable without any
+ /// additional state.
+ ///
+ public string StorageKey { get; set; }
+
+ ///
+ /// Gets or sets the identifier of the interaction the recording belongs to, used to namespace the stored
+ /// media per conversation and to correlate audit records.
+ ///
+ public string InteractionId { get; set; }
+
+ ///
+ /// Gets or sets the media format the recording bytes are encoded in (for example, wav).
+ ///
+ public string Format { get; set; }
+
+ ///
+ /// Gets or sets the raw, unencrypted recording bytes to persist. The store is responsible for encrypting
+ /// them at rest.
+ ///
+ public byte[] Content { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingState.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingState.cs
new file mode 100644
index 000000000..2096e27be
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/RecordingState.cs
@@ -0,0 +1,27 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Identifies the recording state of an interaction.
+///
+public enum RecordingState
+{
+ ///
+ /// The interaction is not being recorded.
+ ///
+ None,
+
+ ///
+ /// The interaction is actively recording.
+ ///
+ Recording,
+
+ ///
+ /// Recording is paused (for example during sensitive data capture).
+ ///
+ Paused,
+
+ ///
+ /// Recording has stopped.
+ ///
+ Stopped,
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneCredentialConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneCredentialConfig.cs
new file mode 100644
index 000000000..dd2c92612
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneCredentialConfig.cs
@@ -0,0 +1,25 @@
+using System.Text.Json.Serialization;
+
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Describes the short-lived browser SIP credential.
+///
+public sealed class SoftPhoneCredentialConfig
+{
+ ///
+ /// Gets or sets the credential type. Supported values are password and ephemeralToken.
+ ///
+ public string Type { get; set; }
+
+ ///
+ /// Gets or sets the credential value.
+ ///
+ public string Value { get; set; }
+
+ ///
+ /// Gets or sets the UTC expiration instant for the credential.
+ ///
+ [JsonPropertyName("expiresAtUtc")]
+ public DateTime ExpiresAtUtc { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceConfig.cs
new file mode 100644
index 000000000..8c5afeb03
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceConfig.cs
@@ -0,0 +1,17 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Describes ICE server configuration for browser media negotiation.
+///
+public sealed class SoftPhoneIceConfig
+{
+ ///
+ /// Gets or sets the ICE servers available to the browser.
+ ///
+ public IList IceServers { get; set; } = [];
+
+ ///
+ /// Gets or sets the ICE transport policy.
+ ///
+ public string IceTransportPolicy { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceServerConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceServerConfig.cs
new file mode 100644
index 000000000..654a9556d
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneIceServerConfig.cs
@@ -0,0 +1,22 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Describes a single ICE server entry.
+///
+public sealed class SoftPhoneIceServerConfig
+{
+ ///
+ /// Gets or sets the STUN or TURN URLs for this server.
+ ///
+ public IList Urls { get; set; } = [];
+
+ ///
+ /// Gets or sets the optional time-limited TURN user name.
+ ///
+ public string Username { get; set; }
+
+ ///
+ /// Gets or sets the optional time-limited TURN credential.
+ ///
+ public string Credential { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneMediaConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneMediaConfig.cs
new file mode 100644
index 000000000..9d10a3580
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneMediaConfig.cs
@@ -0,0 +1,12 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Describes media preferences for the browser soft phone.
+///
+public sealed class SoftPhoneMediaConfig
+{
+ ///
+ /// Gets or sets the preferred audio codecs.
+ ///
+ public IList Codecs { get; set; } = [];
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfig.cs
new file mode 100644
index 000000000..10b5a93d4
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfig.cs
@@ -0,0 +1,37 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Provides the browser soft-phone registration configuration consumed by the page-local media adapter.
+///
+public sealed class SoftPhoneRegistrationConfig
+{
+ ///
+ /// Gets or sets the technical provider name.
+ ///
+ public string Provider { get; set; }
+
+ ///
+ /// Gets or sets the SIP signaling configuration.
+ ///
+ public SoftPhoneSignalingConfig Signaling { get; set; }
+
+ ///
+ /// Gets or sets the short-lived SIP credential.
+ ///
+ public SoftPhoneCredentialConfig Credential { get; set; }
+
+ ///
+ /// Gets or sets the ICE configuration.
+ ///
+ public SoftPhoneIceConfig Ice { get; set; }
+
+ ///
+ /// Gets or sets the media configuration.
+ ///
+ public SoftPhoneMediaConfig Media { get; set; }
+
+ ///
+ /// Gets or sets the soft-phone session metadata.
+ ///
+ public SoftPhoneSessionConfig Session { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfigContext.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfigContext.cs
new file mode 100644
index 000000000..beb918e7d
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneRegistrationConfigContext.cs
@@ -0,0 +1,29 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Describes the current browser soft-phone registration request.
+///
+public sealed class SoftPhoneRegistrationConfigContext
+{
+ ///
+ /// Gets or sets the technical provider name selected for the tenant.
+ ///
+ public string ProviderName { get; set; }
+
+ ///
+ /// Gets or sets the current user identifier.
+ ///
+ public string UserId { get; set; }
+
+ ///
+ /// Gets or sets the display name to present in SIP signaling.
+ ///
+ public string DisplayName { get; set; }
+
+ ///
+ /// Gets or sets the optional interaction identifier associated with this media session. This value is
+ /// non-authoritative metadata only: it must never be used to authorize credential issuance or to
+ /// derive the server-owned media session identity, because it can be supplied by the caller.
+ ///
+ public string InteractionId { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSessionConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSessionConfig.cs
new file mode 100644
index 000000000..de44122c9
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSessionConfig.cs
@@ -0,0 +1,20 @@
+using System.Text.Json.Serialization;
+
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Describes the browser media session associated with a short-lived registration.
+///
+public sealed class SoftPhoneSessionConfig
+{
+ ///
+ /// Gets or sets the interaction identifier bound to the credential.
+ ///
+ public string InteractionId { get; set; }
+
+ ///
+ /// Gets or sets the UTC expiration instant for the browser media session.
+ ///
+ [JsonPropertyName("expiresAtUtc")]
+ public DateTime ExpiresAtUtc { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSignalingConfig.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSignalingConfig.cs
new file mode 100644
index 000000000..283e5639a
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneSignalingConfig.cs
@@ -0,0 +1,27 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Describes the SIP-over-WebSocket signaling endpoint used by the browser media adapter.
+///
+public sealed class SoftPhoneSignalingConfig
+{
+ ///
+ /// Gets or sets the secure WebSocket URL for SIP signaling.
+ ///
+ public string WebSocketUrl { get; set; }
+
+ ///
+ /// Gets or sets the SIP address of record assigned to the browser agent.
+ ///
+ public string SipUri { get; set; }
+
+ ///
+ /// Gets or sets the SIP authorization user.
+ ///
+ public string AuthorizationUser { get; set; }
+
+ ///
+ /// Gets or sets the display name to present in SIP signaling.
+ ///
+ public string DisplayName { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidget.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidget.cs
new file mode 100644
index 000000000..9ec6a8475
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/SoftPhoneWidget.cs
@@ -0,0 +1,37 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Represents the floating soft phone widget rendered through Orchard Core display management.
+///
+public sealed class SoftPhoneWidget
+{
+ ///
+ /// Gets or sets the accent color used by the widget.
+ ///
+ public string AccentColor { get; set; } = "#2f6fed";
+
+ ///
+ /// Gets or sets the maximum number of recent calls displayed in the history tab.
+ ///
+ public int RecentCallsCount { get; set; } = 30;
+
+ ///
+ /// Gets or sets the telephony operations supported by the active provider.
+ ///
+ public TelephonyCapabilities Capabilities { get; set; }
+
+ ///
+ /// Gets or sets the provider's executable audio delivery capabilities.
+ ///
+ public TelephonyAudioCapabilities AudioCapabilities { get; set; }
+
+ ///
+ /// Gets or sets the effective audio delivery mode.
+ ///
+ public TelephonyAudioMode AudioMode { get; set; }
+
+ ///
+ /// Gets or sets the browser media adapter name when browser audio is active.
+ ///
+ public string BrowserMediaAdapterName { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioCapabilities.cs
new file mode 100644
index 000000000..65d8c33c7
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioCapabilities.cs
@@ -0,0 +1,23 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Identifies the live audio delivery modes supported by a telephony provider.
+///
+[Flags]
+public enum TelephonyAudioCapabilities
+{
+ ///
+ /// The provider does not expose an executable agent audio path.
+ ///
+ None = 0,
+
+ ///
+ /// The provider can deliver live audio through a browser media adapter and the agent's microphone.
+ ///
+ Browser = 1 << 0,
+
+ ///
+ /// The provider delivers live audio through an external device or provider-owned application.
+ ///
+ ExternalDevice = 1 << 1,
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioMode.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioMode.cs
new file mode 100644
index 000000000..0ff9d6fb6
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyAudioMode.cs
@@ -0,0 +1,22 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Identifies the effective live audio delivery mode used by the soft phone.
+///
+public enum TelephonyAudioMode
+{
+ ///
+ /// No executable agent audio path is available.
+ ///
+ None,
+
+ ///
+ /// The soft phone captures microphone audio and plays remote audio in the browser.
+ ///
+ Browser,
+
+ ///
+ /// Audio is handled by an external device or provider-owned application.
+ ///
+ ExternalDevice,
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCall.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCall.cs
index 3b74d1766..3b63456d9 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCall.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCall.cs
@@ -50,4 +50,11 @@ public sealed class TelephonyCall
/// Gets or sets the time, in UTC, when the call started.
///
public DateTimeOffset? StartedUtc { get; set; }
+
+ ///
+ /// Gets or sets optional provider-neutral metadata associated with the call.
+ /// Providers and orchestration modules can use this bag to carry routing hints or contextual
+ /// data without extending the shared telephony contracts with provider-specific properties.
+ ///
+ public IDictionary Metadata { get; set; } = new Dictionary();
}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallListLookupResult.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallListLookupResult.cs
new file mode 100644
index 000000000..e6b9fe0a3
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallListLookupResult.cs
@@ -0,0 +1,22 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Represents the result of querying telephony providers for a user's active calls.
+///
+public sealed class TelephonyCallListLookupResult
+{
+ ///
+ /// Gets or sets a value indicating whether every provider lookup completed successfully.
+ ///
+ public bool Succeeded { get; set; }
+
+ ///
+ /// Gets or sets the provider-authoritative active calls.
+ ///
+ public IReadOnlyList Calls { get; set; } = [];
+
+ ///
+ /// Gets or sets the error message when a provider lookup failed.
+ ///
+ public string Error { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallLookupResult.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallLookupResult.cs
new file mode 100644
index 000000000..dce717816
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCallLookupResult.cs
@@ -0,0 +1,27 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Represents the result of querying a telephony provider for the current state of a call.
+///
+public sealed class TelephonyCallLookupResult
+{
+ ///
+ /// Gets or sets a value indicating whether the lookup completed successfully.
+ ///
+ public bool Succeeded { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether the provider still reports the call.
+ ///
+ public bool Found { get; set; }
+
+ ///
+ /// Gets or sets the current provider call state when the lookup succeeded and found the call.
+ ///
+ public TelephonyCall Call { get; set; }
+
+ ///
+ /// Gets or sets the error message when the lookup failed.
+ ///
+ public string Error { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs
index eb313759a..016322c33 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyCapabilities.cs
@@ -38,7 +38,7 @@ public enum TelephonyCapabilities
Mute = 1 << 4,
///
- /// The provider can transfer a call to another destination.
+ /// The provider can transfer a call to another destination without consulting it first.
///
Transfer = 1 << 5,
@@ -56,4 +56,20 @@ public enum TelephonyCapabilities
/// The provider can receive inbound calls.
///
ReceiveCalls = 1 << 8,
+
+ ///
+ /// The provider can send a ringing inbound call to voicemail.
+ ///
+ Voicemail = 1 << 9,
+
+ ///
+ /// The provider can list directory destinations for call transfer.
+ ///
+ Directory = 1 << 10,
+
+ ///
+ /// The provider can perform an attended (warm) transfer, where the transferring party consults the
+ /// destination before the call is released to it.
+ ///
+ AttendedTransfer = 1 << 11,
}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyClientCredentials.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyClientCredentials.cs
index 84dbdb14b..f8c0b0284 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyClientCredentials.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyClientCredentials.cs
@@ -22,6 +22,22 @@ public sealed class TelephonyClientCredentials
///
public DateTimeOffset? ExpiresUtc { get; set; }
+ ///
+ /// Gets or sets the executable audio delivery modes advertised by the provider.
+ ///
+ public TelephonyAudioCapabilities AudioCapabilities { get; set; }
+
+ ///
+ /// Gets or sets the effective audio delivery mode selected for the provider.
+ ///
+ public TelephonyAudioMode AudioMode { get; set; }
+
+ ///
+ /// Gets or sets the browser media adapter name when is
+ /// .
+ ///
+ public string BrowserMediaAdapterName { get; set; }
+
///
/// Gets or sets an optional collection of non-sensitive, provider-specific settings the client
/// SDK needs in order to initialize.
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryEntry.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryEntry.cs
new file mode 100644
index 000000000..44f4989fe
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryEntry.cs
@@ -0,0 +1,37 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Represents one provider directory destination available for call transfer.
+///
+public sealed class TelephonyDirectoryEntry
+{
+ ///
+ /// Gets or sets the provider-specific entry identifier.
+ ///
+ public string Id { get; set; }
+
+ ///
+ /// Gets or sets the human-readable entry name.
+ ///
+ public string DisplayName { get; set; }
+
+ ///
+ /// Gets or sets the destination sent to the provider when transferring a call.
+ ///
+ public string Destination { get; set; }
+
+ ///
+ /// Gets or sets the entry's internal extension, when available.
+ ///
+ public string Extension { get; set; }
+
+ ///
+ /// Gets or sets the entry's external phone number, when available.
+ ///
+ public string PhoneNumber { get; set; }
+
+ ///
+ /// Gets or sets provider-specific status or grouping text.
+ ///
+ public string Detail { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryResult.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryResult.cs
new file mode 100644
index 000000000..ea2fa6de2
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyDirectoryResult.cs
@@ -0,0 +1,22 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Represents the outcome of a provider directory lookup.
+///
+public sealed class TelephonyDirectoryResult
+{
+ ///
+ /// Gets or sets a value indicating whether the directory lookup succeeded.
+ ///
+ public bool Succeeded { get; set; }
+
+ ///
+ /// Gets or sets the directory entries.
+ ///
+ public IReadOnlyList Entries { get; set; } = [];
+
+ ///
+ /// Gets or sets a provider-neutral error message when the lookup fails.
+ ///
+ public string Error { get; set; }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyResult.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyResult.cs
index d7dddca40..bb7627655 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyResult.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/TelephonyResult.cs
@@ -10,6 +10,11 @@ public sealed class TelephonyResult
///
public bool Succeeded { get; init; }
+ ///
+ /// Gets a value indicating whether the provider may have executed the operation but its outcome could not be observed.
+ ///
+ public bool OutcomeUnknown { get; init; }
+
///
/// Gets the error message describing why the operation failed, when is
/// .
@@ -36,4 +41,19 @@ public static TelephonyResult Success(TelephonyCall call = null)
/// A failed .
public static TelephonyResult Failed(string error)
=> new() { Succeeded = false, Error = error };
+
+ ///
+ /// Creates a result for an operation whose provider outcome could not be determined.
+ ///
+ /// The error message describing why the outcome is unknown.
+ /// An indeterminate .
+ public static TelephonyResult Unknown(string error)
+ {
+ return new TelephonyResult
+ {
+ Succeeded = false,
+ OutcomeUnknown = true,
+ Error = error,
+ };
+ }
}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallState.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallState.cs
new file mode 100644
index 000000000..3a7a3dde6
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallState.cs
@@ -0,0 +1,69 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Identifies the normalized, provider-neutral state of a voice call. Providers report their own call
+/// state, which is normalized into these values so ingress, routing, analytics, and the agent and
+/// supervisor experiences can reason about calls independently of any specific provider.
+///
+public enum VoiceCallState
+{
+ ///
+ /// The call has been planned (for example reserved for an outbound dial) but not yet placed.
+ ///
+ Planned,
+
+ ///
+ /// An outbound call is being placed and is awaiting connection.
+ ///
+ Dialing,
+
+ ///
+ /// The call is alerting and waiting for the remote party or agent to answer.
+ ///
+ Ringing,
+
+ ///
+ /// The call is connected and media is flowing.
+ ///
+ Connected,
+
+ ///
+ /// The call is connected but currently on hold.
+ ///
+ OnHold,
+
+ ///
+ /// The call is in the process of ending.
+ ///
+ Ending,
+
+ ///
+ /// The call ended normally.
+ ///
+ Ended,
+
+ ///
+ /// The call failed due to an error or provider failure.
+ ///
+ Failed,
+
+ ///
+ /// The outbound call was not answered.
+ ///
+ NoAnswer,
+
+ ///
+ /// The call was rejected by the remote party or agent.
+ ///
+ Rejected,
+
+ ///
+ /// The call was canceled before it connected.
+ ///
+ Canceled,
+
+ ///
+ /// The call was transferred to another destination.
+ ///
+ Transferred,
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallStateProjection.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallStateProjection.cs
new file mode 100644
index 000000000..b335f8de3
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/Models/VoiceCallStateProjection.cs
@@ -0,0 +1,115 @@
+namespace CrestApps.OrchardCore.Telephony.Models;
+
+///
+/// Projects between the canonical twelve-state voice call vocabulary and the seven-state
+/// telephony soft-phone vocabulary, and derives the terminal voice call state implied by a
+/// provider-reported hangup cause.
+///
+/// This type is the only place either projection may be written. The soft-phone vocabulary is a
+/// strict, lossy projection of the canonical vocabulary: four distinct terminal outcomes
+/// collapse onto or . When each
+/// call site was free to write its own projection, those collapses were applied inconsistently and
+/// in the widening direction they discarded the outcome entirely, so every provider hangup became
+/// regardless of why the call ended.
+///
+///
+public static class VoiceCallStateProjection
+{
+ ///
+ /// Widens a soft-phone call state into the canonical voice call state, refining the
+ /// terminal outcome from the provider-reported hangup cause when one is available.
+ ///
+ /// The soft-phone call state reported by the provider.
+ /// Whether the provider additionally reports the call as held.
+ /// The provider-reported hangup cause, when the call has ended.
+ /// The canonical voice call state.
+ ///
+ /// is the soft phone's "no live call" sentinel rather than a call
+ /// state, so widening it for a call a provider still reports means the call is over. It therefore
+ /// maps to and does not round-trip back from
+ /// .
+ ///
+ public static VoiceCallState ToVoiceCallState(
+ CallState state,
+ bool isOnHold = false,
+ HangupCause? hangupCause = null)
+ {
+ return state switch
+ {
+ CallState.Idle => VoiceCallState.Ended,
+ CallState.Connecting => VoiceCallState.Dialing,
+ CallState.Ringing => VoiceCallState.Ringing,
+ CallState.Connected when isOnHold => VoiceCallState.OnHold,
+ CallState.Connected => VoiceCallState.Connected,
+ CallState.OnHold => VoiceCallState.OnHold,
+ CallState.Disconnected => ToTerminalVoiceCallState(hangupCause, VoiceCallState.Ended),
+ CallState.Failed => ToTerminalVoiceCallState(hangupCause, VoiceCallState.Failed),
+ _ => VoiceCallState.Ended,
+ };
+ }
+
+ ///
+ /// Narrows the canonical Contact Center call state into the soft-phone call state.
+ ///
+ /// The canonical voice call state.
+ /// The soft-phone call state.
+ public static CallState ToTelephonyCallState(VoiceCallState state)
+ {
+ return state switch
+ {
+ VoiceCallState.Planned => CallState.Idle,
+ VoiceCallState.Dialing => CallState.Connecting,
+ VoiceCallState.Ringing => CallState.Ringing,
+ VoiceCallState.Connected => CallState.Connected,
+ VoiceCallState.OnHold => CallState.OnHold,
+ VoiceCallState.Ending => CallState.Disconnected,
+ VoiceCallState.Ended => CallState.Disconnected,
+ VoiceCallState.Transferred => CallState.Disconnected,
+ VoiceCallState.Canceled => CallState.Disconnected,
+ VoiceCallState.NoAnswer => CallState.Failed,
+ VoiceCallState.Rejected => CallState.Failed,
+ VoiceCallState.Failed => CallState.Failed,
+ _ => CallState.Idle,
+ };
+ }
+
+ ///
+ /// Resolves the terminal Contact Center call state implied by a provider-reported hangup cause.
+ ///
+ /// The provider-reported hangup cause, when one was reported.
+ /// The terminal state to use when no usable cause was reported.
+ /// The terminal Contact Center call state.
+ public static VoiceCallState ToTerminalVoiceCallState(
+ HangupCause? hangupCause,
+ VoiceCallState fallback)
+ {
+ return hangupCause switch
+ {
+ HangupCause.NormalClearing => VoiceCallState.Ended,
+ HangupCause.AnsweringMachine => VoiceCallState.Ended,
+ HangupCause.Busy => VoiceCallState.Rejected,
+ HangupCause.Rejected => VoiceCallState.Rejected,
+ HangupCause.NoAnswer => VoiceCallState.NoAnswer,
+ HangupCause.Canceled => VoiceCallState.Canceled,
+ HangupCause.Congestion => VoiceCallState.Failed,
+ HangupCause.Failed => VoiceCallState.Failed,
+ _ => fallback,
+ };
+ }
+
+ ///
+ /// Determines whether the supplied Contact Center call state is terminal, meaning the call can
+ /// no longer change state.
+ ///
+ /// The Contact Center call state to evaluate.
+ /// when the state is terminal; otherwise .
+ public static bool IsTerminal(VoiceCallState state)
+ {
+ return state is VoiceCallState.Ended or
+ VoiceCallState.Failed or
+ VoiceCallState.NoAnswer or
+ VoiceCallState.Rejected or
+ VoiceCallState.Canceled or
+ VoiceCallState.Transferred;
+ }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyAudioModeResolver.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyAudioModeResolver.cs
new file mode 100644
index 000000000..7cf7e52bb
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyAudioModeResolver.cs
@@ -0,0 +1,48 @@
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Resolves a provider's effective audio mode from its executable capabilities and configuration.
+///
+public static class TelephonyAudioModeResolver
+{
+ ///
+ /// Resolves the effective audio mode.
+ ///
+ /// The provider's executable audio capabilities.
+ /// The provider-selected mode when multiple modes are supported.
+ /// The registered browser media adapter name.
+ /// The effective audio mode, or when the configuration is not executable.
+ public static TelephonyAudioMode Resolve(
+ TelephonyAudioCapabilities capabilities,
+ TelephonyAudioMode configuredMode,
+ string browserMediaAdapterName)
+ {
+ var supportsBrowser = capabilities.HasFlag(TelephonyAudioCapabilities.Browser) &&
+ !string.IsNullOrWhiteSpace(browserMediaAdapterName);
+ var supportsExternalDevice = capabilities.HasFlag(TelephonyAudioCapabilities.ExternalDevice);
+
+ if (supportsBrowser && supportsExternalDevice)
+ {
+ return configuredMode switch
+ {
+ TelephonyAudioMode.Browser => TelephonyAudioMode.Browser,
+ TelephonyAudioMode.ExternalDevice => TelephonyAudioMode.ExternalDevice,
+ _ => TelephonyAudioMode.None,
+ };
+ }
+
+ if (supportsBrowser)
+ {
+ return TelephonyAudioMode.Browser;
+ }
+
+ if (supportsExternalDevice)
+ {
+ return TelephonyAudioMode.ExternalDevice;
+ }
+
+ return TelephonyAudioMode.None;
+ }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCapabilityContracts.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCapabilityContracts.cs
new file mode 100644
index 000000000..e83c4c0d0
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCapabilityContracts.cs
@@ -0,0 +1,40 @@
+using System.Collections.Frozen;
+using CrestApps.OrchardCore.Telephony.Models;
+
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Maps each advertised telephony capability to the executable contract a provider must implement before the
+/// capability may be exercised.
+///
+public static class TelephonyCapabilityContracts
+{
+ private static readonly FrozenDictionary _contractsByCapability = new Dictionary
+ {
+ [TelephonyCapabilities.Dial] = typeof(ITelephonyCallControlProvider),
+ [TelephonyCapabilities.Hangup] = typeof(ITelephonyCallControlProvider),
+ [TelephonyCapabilities.Hold] = typeof(ITelephonyHoldProvider),
+ [TelephonyCapabilities.Resume] = typeof(ITelephonyHoldProvider),
+ [TelephonyCapabilities.Mute] = typeof(ITelephonyMuteProvider),
+ [TelephonyCapabilities.Transfer] = typeof(ITelephonyTransferProvider),
+ [TelephonyCapabilities.AttendedTransfer] = typeof(ITelephonyAttendedTransferProvider),
+ [TelephonyCapabilities.Merge] = typeof(ITelephonyConferenceProvider),
+ [TelephonyCapabilities.SendDigits] = typeof(ITelephonyDtmfProvider),
+ [TelephonyCapabilities.ReceiveCalls] = typeof(ITelephonyInboundCallProvider),
+ [TelephonyCapabilities.Voicemail] = typeof(ITelephonyVoicemailProvider),
+ [TelephonyCapabilities.Directory] = typeof(ITelephonyDirectoryProvider),
+ }.ToFrozenDictionary();
+
+ ///
+ /// Gets the executable contract required by each advertised capability.
+ ///
+ public static IReadOnlyDictionary ContractsByCapability => _contractsByCapability;
+
+ ///
+ /// Gets the executable contract required by the given capability.
+ ///
+ /// The advertised capability.
+ /// The contract type, or when the capability requires none.
+ public static Type GetContract(TelephonyCapabilities capability)
+ => _contractsByCapability.TryGetValue(capability, out var contract) ? contract : null;
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandNotAdmittedException.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandNotAdmittedException.cs
new file mode 100644
index 000000000..49194dd92
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandNotAdmittedException.cs
@@ -0,0 +1,33 @@
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Thrown by when a telephony provider mutation is refused because
+/// the host is shutting down. The provider is never contacted, so the command is guaranteed not to have
+/// been applied and the caller may safely treat it as a definite non-application (rather than an
+/// indeterminate outcome).
+///
+///
+/// Derives from so callers that already treat cancellation as an
+/// indeterminate outcome continue to fail safe, while callers that want the more precise
+/// "definitely not applied" signal can catch this type first.
+///
+public sealed class TelephonyCommandNotAdmittedException : OperationCanceledException
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public TelephonyCommandNotAdmittedException()
+ : base("The telephony command was refused because the application is stopping.")
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class with the
+ /// cancellation token that triggered the refusal.
+ ///
+ /// The shutdown token that caused the command to be refused.
+ public TelephonyCommandNotAdmittedException(CancellationToken cancellationToken)
+ : base("The telephony command was refused because the application is stopping.", cancellationToken)
+ {
+ }
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandOptions.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandOptions.cs
new file mode 100644
index 000000000..3a65a01d7
--- /dev/null
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyCommandOptions.cs
@@ -0,0 +1,29 @@
+namespace CrestApps.OrchardCore.Telephony;
+
+///
+/// Defines the server-owned execution deadline for telephony provider mutations.
+///
+public sealed class TelephonyCommandOptions
+{
+ ///
+ /// The default provider mutation timeout, in seconds.
+ ///
+ public const int DefaultTimeoutSeconds = 10;
+
+ ///
+ /// The minimum provider mutation timeout, in seconds.
+ ///
+ public const int MinimumTimeoutSeconds = 1;
+
+ ///
+ /// The maximum provider mutation timeout, in seconds.
+ ///
+ public const int MaximumTimeoutSeconds = 120;
+
+ ///
+ /// Gets or sets the maximum duration of one telephony provider mutation before its outcome is
+ /// treated as unknown. This outer command deadline intentionally supersedes longer
+ /// provider-specific retry budgets.
+ ///
+ public TimeSpan Timeout { get; set; } = TimeSpan.FromSeconds(DefaultTimeoutSeconds);
+}
diff --git a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs
index f53f44b3e..53411bdcd 100644
--- a/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs
+++ b/src/Abstractions/CrestApps.OrchardCore.Telephony.Abstractions/TelephonyConstants.cs
@@ -17,6 +17,34 @@ public static class TelephonyConstants
///
public const string TokenProtectorPurpose = "CrestApps.OrchardCore.Telephony.UserTokens";
+ ///
+ /// The data protection purpose used to encrypt conversation recording media at rest in the default local
+ /// recording media store.
+ ///
+ public const string RecordingMediaProtectorPurpose = "CrestApps.OrchardCore.Telephony.RecordingMedia";
+
+ ///
+ /// The tenant-scoped application-data folder name under which the default local recording media store
+ /// persists encrypted recordings.
+ ///
+ public const string RecordingMediaFolderName = "RecordingMedia";
+
+ ///
+ /// Contains metadata keys that have provider-neutral command semantics.
+ ///
+ public static class RequestMetadata
+ {
+ ///
+ /// Identifies a stable command that providers should use for idempotent execution when supported.
+ ///
+ public const string IdempotencyKey = "idempotencyKey";
+
+ ///
+ /// Identifies the monotonic fence token associated with an idempotent provider command.
+ ///
+ public const string FenceToken = "commandFenceToken";
+ }
+
///
/// Contains the well-known authentication scheme identifiers a telephony provider can use.
///
@@ -65,9 +93,8 @@ public static class Feature
public const string SoftPhone = "CrestApps.OrchardCore.Telephony.SoftPhone";
///
- /// The legacy identifier of the soft phone feature.
+ /// The identifier of the Telephony administration feature.
///
- [Obsolete("Use SoftPhone instead.")]
- public const string SoftPhoneWidget = SoftPhone;
+ public const string Admin = "CrestApps.OrchardCore.Telephony.Admin";
}
}
diff --git a/src/Core/CrestApps.OrchardCore.AI.Core/Orchestration/LocalToolRegistryProvider.cs b/src/Core/CrestApps.OrchardCore.AI.Core/Orchestration/LocalToolRegistryProvider.cs
index a798a4fa3..3c356a847 100644
--- a/src/Core/CrestApps.OrchardCore.AI.Core/Orchestration/LocalToolRegistryProvider.cs
+++ b/src/Core/CrestApps.OrchardCore.AI.Core/Orchestration/LocalToolRegistryProvider.cs
@@ -1,6 +1,6 @@
using CrestApps.Core.AI.Models;
-using CrestApps.Core.Security;
using CrestApps.Core.AI.Tooling;
+using CrestApps.Core.Security;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.AI;
using Microsoft.Extensions.DependencyInjection;
diff --git a/src/Core/CrestApps.OrchardCore.AI.Core/ServiceCollectionExtensions.cs b/src/Core/CrestApps.OrchardCore.AI.Core/ServiceCollectionExtensions.cs
index c08e1a2e3..463661f47 100644
--- a/src/Core/CrestApps.OrchardCore.AI.Core/ServiceCollectionExtensions.cs
+++ b/src/Core/CrestApps.OrchardCore.AI.Core/ServiceCollectionExtensions.cs
@@ -5,8 +5,6 @@
using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Services;
using CrestApps.Core.AI.Tooling;
-using CrestApps.Core.Builders;
-using CrestApps.Core.Data.YesSql;
using CrestApps.Core.Infrastructure.Indexing;
using CrestApps.Core.Services;
using CrestApps.OrchardCore.AI.Core.Handlers;
diff --git a/src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIChatSessionManager.cs b/src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIChatSessionManager.cs
index ed6d36373..00600ce24 100644
--- a/src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIChatSessionManager.cs
+++ b/src/Core/CrestApps.OrchardCore.AI.Core/Services/DefaultAIChatSessionManager.cs
@@ -8,10 +8,8 @@
using CrestApps.Core.Data.YesSql;
using CrestApps.Core.Data.YesSql.Indexes.AIChat;
using Microsoft.AspNetCore.Http;
-using Microsoft.Extensions.AI;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
-using OrchardCore;
using OrchardCore.Modules;
using YesSql;
using ISession = YesSql.ISession;
diff --git a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs
index 9f3d1efd7..251c3e8d8 100644
--- a/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs
+++ b/src/Core/CrestApps.OrchardCore.Omnichannel.Core/Models/OmnichannelActivity.cs
@@ -127,6 +127,14 @@ public sealed class OmnichannelActivity : CatalogItem
///
public int Attempts { get; set; } = 1;
+ ///
+ /// Gets or sets the number of times the automated-activities background processor has attempted to start this
+ /// activity and failed. This is an internal technical retry counter used only to back off and eventually fail a
+ /// permanently failing automated activity. It is deliberately separate from , which is a
+ /// routing/dialing concept projected from the contact-center work state and surfaced in reports and the UI.
+ ///
+ public int ProcessingAttempts { get; set; }
+
///
/// Gets or sets the assigned to id.
///
diff --git a/src/CrestApps.Docs/CrestApps.Docs.csproj b/src/CrestApps.Docs/CrestApps.Docs.csproj
index f4e56132e..3077c23a9 100644
--- a/src/CrestApps.Docs/CrestApps.Docs.csproj
+++ b/src/CrestApps.Docs/CrestApps.Docs.csproj
@@ -3,6 +3,7 @@
$(CommonTargetFrameworks)false
+ false
diff --git a/src/CrestApps.Docs/docs/changelog/v2.0.0.md b/src/CrestApps.Docs/docs/changelog/v2.0.0.md
index eb56f7edb..53fda6167 100644
--- a/src/CrestApps.Docs/docs/changelog/v2.0.0.md
+++ b/src/CrestApps.Docs/docs/changelog/v2.0.0.md
@@ -290,6 +290,7 @@ At a high level, the platform changes are:
- The **Manage Activities** filters now replace every separate _from_/_to_ date input — the contact **Do not call** window, and the activity **Scheduled** and **Created** windows — with the shared `DateRangePicker` control, so each date filter offers the same presets, custom range, and on-or-before/on-or-after date-time bounds used across reports.
- Admin editors in Omnichannel Management now use the current `ocat-*` admin layout classes instead of older generic Bootstrap field markup.
- Omnichannel configuration now travels between environments through standard Orchard Core deployment steps and recipe steps. Each configurable entity — dispositions, channel endpoints, campaign groups, campaigns, and subject actions — has its own **Add** deployment step under the **Omnichannel** category and a matching recipe step (`OmnichannelDisposition`, `OmnichannelChannelEndpoint`, `OmnichannelCampaignGroup`, `OmnichannelCampaign`, and `OmnichannelSubjectAction`). Exports preserve each entry's identifier, so re-importing into another tenant updates the existing entry and keeps cross-references intact. Import binds every portable member by reflecting over the entity, matching what export writes, so a member added to an entity travels without touching the step; members owned by the environment - creation and modification stamps, and ownership - are never carried, and the destination stamps its own. Binding the members by hand had made the import lossy: a campaign arrived at the destination with its description missing. Enabling **Omnichannel Activities** without **Omnichannel Managements** also threw at tenant activation, because `OmnichannelContentTypeProvider` was registered in the Managements feature while `SubjectFlowSettingsService` in the Activities feature depended on it; the registration moved to the feature that consumes it.
+- Creating or editing a campaign group no longer throws `InvalidOperationException: The shape type 'OmnichannelCampaignGroup_Edit' is not found for the theme 'TheAdmin'`. The campaign group editor was missing its root editor view; the `OmnichannelCampaignGroup.Edit.cshtml` template that renders the editor's `Content` zone has been added, matching the other omnichannel catalog editors.
#### Subject configuration moved to content-type part settings
@@ -479,6 +480,12 @@ At a high level, the platform changes are:
- The telephony abstractions gained PKCE support (`TelephonyAuthorizationContext.CodeChallenge`/`CodeChallengeMethod`, `TelephonyCodeExchangeContext.CodeVerifier`, and `TelephonyAuthorizationRequest`) and token revocation (`ITelephonyAuthenticationProvider.SupportsProofKeyForCodeExchange` and `RevokeTokensAsync`), so any OAuth-based telephony provider can opt in to PKCE and provider-side token revocation on disconnect. OAuth completion now returns a `TelephonyResult`, so callers can inspect the failure reason instead of receiving only a boolean.
- The DialPad provider now resolves a named HTTP client configured by startup with standard ASP.NET Core HTTP resiliency policies for DialPad REST API and OAuth token requests.
+#### Provider capability abstractions
+
+- The `CrestApps.OrchardCore.Telephony.Abstractions` package gains a set of granular, opt-in provider-capability contracts so a provider can advertise exactly the features it implements without every provider carrying the full surface. These include call control, call state, inbound calls, hold, mute, DTMF, transfer, attended transfer, conference, voicemail, directory, and audio (soft phone media and ICE) provider interfaces, together with supporting models such as `IncomingCallContext`, `ProviderIdentity`, `ProviderVoiceEvent`, `TelephonyAudioMode`, `TelephonyDirectoryResult`, and the recording-media contracts. Every contract is additive, so existing `ITelephonyProvider` implementations keep working unchanged.
+- A dedicated **Telephony Administration** feature (`CrestApps.OrchardCore.Telephony.Admin`) now owns the telephony provider settings screen and its administration menu entry. The core telephony services, hub, and provider bindings remain in the base feature, so a headless deployment can enable the capability and configure it from a recipe or an API without carrying an administration surface.
+- The Telephony and DialPad modules and features are grouped under a **Telephony** category in the features list (previously **Communications**), so related telephony capabilities stay together.
+
### Time Zones
#### Time zone maps
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/Drivers/AIProfilePromptSecurityDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.AI.Chat/Drivers/AIProfilePromptSecurityDisplayDriver.cs
index 40d2b2856..1a3631bfd 100644
--- a/src/Modules/CrestApps.OrchardCore.AI.Chat/Drivers/AIProfilePromptSecurityDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/Drivers/AIProfilePromptSecurityDisplayDriver.cs
@@ -5,7 +5,6 @@
using Microsoft.Extensions.Options;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
-using OrchardCore.Mvc.ModelBinding;
namespace CrestApps.OrchardCore.AI.Chat.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.AI.Chat/Drivers/AIProfileTemplatePromptSecurityDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.AI.Chat/Drivers/AIProfileTemplatePromptSecurityDisplayDriver.cs
index 43f5b8240..f87d684d2 100644
--- a/src/Modules/CrestApps.OrchardCore.AI.Chat/Drivers/AIProfileTemplatePromptSecurityDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.AI.Chat/Drivers/AIProfileTemplatePromptSecurityDisplayDriver.cs
@@ -7,7 +7,6 @@
using Microsoft.Extensions.Options;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
-using OrchardCore.Mvc.ModelBinding;
namespace CrestApps.OrchardCore.AI.Chat.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.AI.DataSources/Controllers/DataSourcesController.cs b/src/Modules/CrestApps.OrchardCore.AI.DataSources/Controllers/DataSourcesController.cs
index 36334cb9c..999548a15 100644
--- a/src/Modules/CrestApps.OrchardCore.AI.DataSources/Controllers/DataSourcesController.cs
+++ b/src/Modules/CrestApps.OrchardCore.AI.DataSources/Controllers/DataSourcesController.cs
@@ -1,4 +1,3 @@
-using CrestApps.Core.AI.DataSources;
using CrestApps.Core.AI.Models;
using CrestApps.Core.AI.Services;
using CrestApps.Core.Services;
diff --git a/src/Modules/CrestApps.OrchardCore.AI.DataSources/Drivers/AIDataSourceDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.AI.DataSources/Drivers/AIDataSourceDisplayDriver.cs
index bdc455b1b..c1070c01d 100644
--- a/src/Modules/CrestApps.OrchardCore.AI.DataSources/Drivers/AIDataSourceDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.AI.DataSources/Drivers/AIDataSourceDisplayDriver.cs
@@ -1,7 +1,5 @@
-using CrestApps.Core.AI.Memory;
using CrestApps.Core.AI.Models;
using CrestApps.Core.Infrastructure;
-using CrestApps.OrchardCore.AI.Core;
using CrestApps.OrchardCore.AI.DataSources.ViewModels;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.Extensions.Localization;
diff --git a/src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ExportFilesBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ExportFilesBackgroundTask.cs
index 06fc85c0f..74abf0728 100644
--- a/src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ExportFilesBackgroundTask.cs
+++ b/src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ExportFilesBackgroundTask.cs
@@ -26,9 +26,9 @@ public sealed class ExportFilesBackgroundTask : IBackgroundTask
private static readonly TimeSpan _exportLockExpiration = TimeSpan.FromMinutes(30);
public Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)
- => ProcessEntriesAsync(serviceProvider, cancellationToken);
+ => ProcessEntriesAsync(serviceProvider, cancellationToken: cancellationToken);
- internal static async Task ProcessEntriesAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken, string entryId = null)
+ internal static async Task ProcessEntriesAsync(IServiceProvider serviceProvider, string entryId = null, CancellationToken cancellationToken = default)
{
var session = serviceProvider.GetRequiredService();
var distributedLock = serviceProvider.GetRequiredService();
diff --git a/src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ImportFilesBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ImportFilesBackgroundTask.cs
index 66cc5c814..741bfb41b 100644
--- a/src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ImportFilesBackgroundTask.cs
+++ b/src/Modules/CrestApps.OrchardCore.ContentTransfer/BackgroundTasks/ImportFilesBackgroundTask.cs
@@ -28,9 +28,9 @@ public sealed class ImportFilesBackgroundTask : IBackgroundTask
private static readonly TimeSpan _importLockExpiration = TimeSpan.FromMinutes(30);
public Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)
- => ProcessEntriesAsync(serviceProvider, cancellationToken);
+ => ProcessEntriesAsync(serviceProvider, cancellationToken: cancellationToken);
- internal static async Task ProcessEntriesAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken, string entryId = null)
+ internal static async Task ProcessEntriesAsync(IServiceProvider serviceProvider, string entryId = null, CancellationToken cancellationToken = default)
{
var session = serviceProvider.GetRequiredService();
var distributedLock = serviceProvider.GetRequiredService();
diff --git a/src/Modules/CrestApps.OrchardCore.ContentTransfer/Controllers/AdminController.cs b/src/Modules/CrestApps.OrchardCore.ContentTransfer/Controllers/AdminController.cs
index 2c7031e63..7b3ec609e 100644
--- a/src/Modules/CrestApps.OrchardCore.ContentTransfer/Controllers/AdminController.cs
+++ b/src/Modules/CrestApps.OrchardCore.ContentTransfer/Controllers/AdminController.cs
@@ -1013,7 +1013,7 @@ private static void TriggerImportProcessing(string entryId)
await HttpBackgroundJob.ExecuteAfterEndOfRequestAsync(
$"content-transfer-import-{entryId}",
entryId,
- static (backgroundScope, id) => BackgroundTasks.ImportFilesBackgroundTask.ProcessEntriesAsync(backgroundScope.ServiceProvider, CancellationToken.None, id));
+ static (backgroundScope, id) => BackgroundTasks.ImportFilesBackgroundTask.ProcessEntriesAsync(backgroundScope.ServiceProvider, id, CancellationToken.None));
});
}
@@ -1024,7 +1024,7 @@ private static void TriggerExportProcessing(string entryId)
await HttpBackgroundJob.ExecuteAfterEndOfRequestAsync(
$"content-transfer-export-{entryId}",
entryId,
- static (backgroundScope, id) => BackgroundTasks.ExportFilesBackgroundTask.ProcessEntriesAsync(backgroundScope.ServiceProvider, CancellationToken.None, id));
+ static (backgroundScope, id) => BackgroundTasks.ExportFilesBackgroundTask.ProcessEntriesAsync(backgroundScope.ServiceProvider, id, CancellationToken.None));
});
}
diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/CrestApps.OrchardCore.DialPad.csproj b/src/Modules/CrestApps.OrchardCore.DialPad/CrestApps.OrchardCore.DialPad.csproj
index 86edd9296..ab31ceade 100644
--- a/src/Modules/CrestApps.OrchardCore.DialPad/CrestApps.OrchardCore.DialPad.csproj
+++ b/src/Modules/CrestApps.OrchardCore.DialPad/CrestApps.OrchardCore.DialPad.csproj
@@ -26,6 +26,7 @@
+
diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs b/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs
index 12e66b442..c04416002 100644
--- a/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs
+++ b/src/Modules/CrestApps.OrchardCore.DialPad/DialPadConstants.cs
@@ -22,6 +22,11 @@ public static class DialPadConstants
///
public const string OAuthProtectorName = "DialPad.OAuth";
+ ///
+ /// The name of the data protector used to protect the DialPad webhook signing secret.
+ ///
+ public const string WebhookProtectorName = "DialPad.Webhook";
+
///
/// The DialPad OAuth scope that allows access to a refresh token so access tokens can be renewed
/// without prompting the user to reconnect.
diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs
index 412b01b5a..98f3136ea 100644
--- a/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.DialPad/Drivers/DialPadSettingsDisplayDriver.cs
@@ -75,7 +75,8 @@ public override IDisplayResult Edit(ISite site, DialPadSettings settings, BuildE
model.OutboundCallerId = settings.OutboundCallerId;
model.HasApiToken = !string.IsNullOrEmpty(settings.ApiToken);
model.HasClientSecret = !string.IsNullOrEmpty(settings.ClientSecret);
- }).Location("Content:10#DialPad;15")
+ model.HasWebhookSigningSecret = !string.IsNullOrEmpty(settings.WebhookSigningSecret);
+ }).Location("Content:10#DialPad")
.RenderWhen(() => _authorizationService.AuthorizeAsync(_httpContextAccessor.HttpContext?.User, TelephonyPermissions.ManageTelephonySettings))
.OnGroup(SettingsGroupId);
}
@@ -174,6 +175,16 @@ public override async Task UpdateAsync(ISite site, DialPadSettin
settings.ClientSecret = protectedSecret;
}
+
+ if (!string.IsNullOrWhiteSpace(model.WebhookSigningSecret))
+ {
+ var protector = _dataProtectionProvider.CreateProtector(DialPadConstants.WebhookProtectorName);
+ var protectedWebhookSecret = protector.Protect(model.WebhookSigningSecret);
+
+ hasChanges |= settings.WebhookSigningSecret != protectedWebhookSecret;
+
+ settings.WebhookSigningSecret = protectedWebhookSecret;
+ }
}
if (context.Updater.ModelState.IsValid && settings.IsEnabled && string.IsNullOrEmpty(telephonySettings.DefaultProviderName))
diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs
index 89a432de5..7a09e3f2c 100644
--- a/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs
+++ b/src/Modules/CrestApps.OrchardCore.DialPad/Manifest.cs
@@ -9,14 +9,14 @@
Website = CrestAppsManifestConstants.Website,
Version = CrestAppsManifestConstants.Version,
Description = "Integrates the DialPad telephony platform with the Telephony soft phone.",
- Category = "Communications"
+ Category = "Telephony"
)]
[assembly: Feature(
Id = DialPadConstants.Feature.Area,
Name = "DialPad",
Description = "Provides the DialPad telephony provider and its settings.",
- Category = "Communications",
+ Category = "Telephony",
Dependencies =
[
TelephonyConstants.Feature.Area,
diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs
index eef60cf72..151c5466c 100644
--- a/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs
+++ b/src/Modules/CrestApps.OrchardCore.DialPad/Models/DialPadSettings.cs
@@ -74,4 +74,10 @@ public bool UseOAuth
/// Gets or sets the space-separated OAuth scopes requested during authorization.
///
public string Scopes { get; set; }
+
+ ///
+ /// Gets or sets the protected secret DialPad uses to sign call-event webhooks (JWT HS256). The value
+ /// is stored encrypted using the data protection provider. Inbound webhooks are rejected when empty.
+ ///
+ public string WebhookSigningSecret { get; set; }
}
diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs
index ebb1349d0..a8cdbcf2f 100644
--- a/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs
+++ b/src/Modules/CrestApps.OrchardCore.DialPad/Services/DialPadTelephonyProvider.cs
@@ -1,6 +1,9 @@
+using System.Globalization;
+using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
+using CrestApps.Core.Support;
using CrestApps.OrchardCore.DialPad.Models;
using CrestApps.OrchardCore.Telephony;
using CrestApps.OrchardCore.Telephony.Models;
@@ -18,7 +21,22 @@ namespace CrestApps.OrchardCore.DialPad.Services;
/// API key and per-user OAuth 2.0 authentication. All call control happens server-side, so the soft
/// phone client never talks to DialPad directly.
///
-public sealed class DialPadTelephonyProvider : ITelephonyProvider, ITelephonyAuthenticationProvider
+public sealed class DialPadTelephonyProvider :
+ ITelephonyProvider,
+ ITelephonyCallControlProvider,
+ ITelephonyInboundCallProvider,
+ ITelephonyHoldProvider,
+ ITelephonyMuteProvider,
+ ITelephonyTransferProvider,
+ ITelephonyConferenceProvider,
+ ITelephonyDtmfProvider,
+ ITelephonyVoicemailProvider,
+ ITelephonySoftPhoneCredentialsProvider,
+ ITelephonyAttendedTransferProvider,
+ ITelephonyAudioProvider,
+ ITelephonyAuthenticationProvider,
+ ITelephonyCallStateProvider,
+ ITelephonyDirectoryProvider
{
private readonly ISiteService _siteService;
private readonly IDataProtectionProvider _dataProtectionProvider;
@@ -79,12 +97,24 @@ public TelephonyCapabilities Capabilities
TelephonyCapabilities.Resume |
TelephonyCapabilities.Mute |
TelephonyCapabilities.Transfer |
+ TelephonyCapabilities.AttendedTransfer |
TelephonyCapabilities.Merge |
TelephonyCapabilities.SendDigits |
- TelephonyCapabilities.ReceiveCalls;
+ TelephonyCapabilities.ReceiveCalls |
+ TelephonyCapabilities.Voicemail |
+ TelephonyCapabilities.Directory;
}
}
+ ///
+ public TelephonyAudioCapabilities AudioCapabilities => TelephonyAudioCapabilities.ExternalDevice;
+
+ ///
+ public TelephonyAudioMode ConfiguredAudioMode => TelephonyAudioMode.ExternalDevice;
+
+ ///
+ public string BrowserMediaAdapterName => null;
+
///
public bool RequiresUserAuthentication
{
@@ -142,12 +172,30 @@ public async Task DialAsync(DialRequest request, CancellationTo
var client = CreateClient(settings, bearerToken);
using var content = JsonContent.Create(body);
- using var response = await client.PostAsync("call", content, cancellationToken);
+ using var requestMessage = new HttpRequestMessage(HttpMethod.Post, "call")
+ {
+ Content = content,
+ };
+
+ if (request.Metadata?.TryGetValue(
+ TelephonyConstants.RequestMetadata.IdempotencyKey,
+ out var idempotencyKey) == true &&
+ !string.IsNullOrWhiteSpace(idempotencyKey))
+ {
+ requestMessage.Headers.TryAddWithoutValidation("Idempotency-Key", idempotencyKey);
+ }
+
+ using var response = await client.SendAsync(requestMessage, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogError("DialPad rejected a dial request with status code {StatusCode}.", response.StatusCode);
+ if (IsAmbiguousDialStatusCode(response.StatusCode))
+ {
+ return TelephonyResult.Unknown(S["DialPad did not confirm whether the call was placed."].Value);
+ }
+
return TelephonyResult.Failed(S["DialPad could not place the call."].Value);
}
@@ -166,11 +214,121 @@ public async Task DialAsync(DialRequest request, CancellationTo
return TelephonyResult.Success(call);
}
- catch (Exception ex)
+
+ catch (Exception ex) when (ex is HttpRequestException or OperationCanceledException)
{
_logger.LogError(ex, "An error occurred while placing a DialPad call.");
- return TelephonyResult.Failed(S["DialPad could not place the call. Error: {0}", ex.Message].Value);
+ return TelephonyResult.Unknown(S["DialPad did not confirm whether the call was placed."].Value);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An error occurred while preparing a DialPad call.");
+
+ return TelephonyResult.Failed(S["DialPad could not place the call."].Value);
+ }
+ }
+
+ private static bool IsAmbiguousDialStatusCode(HttpStatusCode statusCode)
+ {
+ return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests ||
+ (int)statusCode >= 500;
+ }
+
+ ///
+ public async Task GetCallStateAsync(string callId, CancellationToken cancellationToken = default)
+ {
+ if (string.IsNullOrWhiteSpace(callId))
+ {
+ return new TelephonyCallLookupResult
+ {
+ Succeeded = false,
+ Error = S["A call id is required to query the call state."].Value,
+ };
+ }
+
+ var settings = await GetResolvedSettingsAsync();
+
+ if (!IsConfigured(settings))
+ {
+ return new TelephonyCallLookupResult
+ {
+ Succeeded = false,
+ Error = NotConfigured().Error,
+ };
+ }
+
+ var bearerToken = await GetBearerTokenAsync(settings, cancellationToken);
+
+ if (string.IsNullOrEmpty(bearerToken))
+ {
+ return new TelephonyCallLookupResult
+ {
+ Succeeded = false,
+ Error = NotConnected().Error,
+ };
+ }
+
+ try
+ {
+ var client = CreateClient(settings, bearerToken);
+ using var response = await client.GetAsync($"call/{Uri.EscapeDataString(callId)}", cancellationToken);
+
+ if (response.StatusCode == HttpStatusCode.NotFound)
+ {
+ return new TelephonyCallLookupResult
+ {
+ Succeeded = true,
+ Found = false,
+ };
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogError("DialPad rejected a call-state lookup for call {CallId} with status code {StatusCode}.", callId.SanitizeLogValue(), response.StatusCode);
+
+ return new TelephonyCallLookupResult
+ {
+ Succeeded = false,
+ Error = S["DialPad could not query the call state."].Value,
+ };
+ }
+
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
+ var root = document.RootElement;
+ var stateText = ReadString(root, "status") ?? ReadString(root, "state");
+ var state = TryMapLookupState(stateText, out var mappedState)
+ ? mappedState
+ : CallState.Connected;
+ var call = BuildCall(
+ callId,
+ state,
+ isMuted: ReadBoolean(root, "is_muted"),
+ isOnHold: state == CallState.OnHold,
+ direction: ResolveDirection(ReadString(root, "direction")));
+
+ call.From = ReadString(root, "external_number") ?? ReadString(root, "from");
+ call.To = ReadString(root, "target") ?? ReadString(root, "internal_number") ?? ReadString(root, "to");
+ call.StartedUtc = ReadDateTimeOffset(root, "date_started") ?? ReadDateTimeOffset(root, "date_connected");
+ call.Metadata["dialPadStatus"] = stateText ?? string.Empty;
+
+ return new TelephonyCallLookupResult
+ {
+ Succeeded = true,
+ Found = true,
+ Call = call,
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An error occurred while querying the DialPad call state for call {CallId}.", callId.SanitizeLogValue());
+
+ return new TelephonyCallLookupResult
+ {
+ Succeeded = false,
+ Error = S["DialPad could not query the call state."].Value,
+ };
}
}
@@ -194,6 +352,10 @@ public Task MuteAsync(CallReference call, CancellationToken can
public Task UnmuteAsync(CallReference call, CancellationToken cancellationToken = default)
=> ExecuteCallActionAsync(call?.CallId, "unmute", body: null, () => BuildCall(call?.CallId, CallState.Connected), cancellationToken);
+ ///
+ public Task StartAttendedTransferAsync(TransferRequest request, CancellationToken cancellationToken = default)
+ => TransferAsync(request, cancellationToken);
+
///
public Task TransferAsync(TransferRequest request, CancellationToken cancellationToken = default)
{
@@ -213,19 +375,47 @@ public Task TransferAsync(TransferRequest request, Cancellation
}
///
- public Task MergeAsync(MergeRequest request, CancellationToken cancellationToken = default)
+ public async Task MergeAsync(MergeRequest request, CancellationToken cancellationToken = default)
{
- if (request is null || string.IsNullOrWhiteSpace(request.SecondaryCallId))
+ var callIds = request?.GetCallIds();
+
+ if (callIds is null || callIds.Count < 2)
{
- return Task.FromResult(TelephonyResult.Failed(S["A second call is required to merge calls."].Value));
+ return TelephonyResult.Failed(S["At least two calls are required to merge calls."].Value);
}
- return ExecuteCallActionAsync(
- request.PrimaryCallId,
- "merge",
- new Dictionary { ["target_call_id"] = request.SecondaryCallId },
- () => BuildCall(request.PrimaryCallId, CallState.Connected),
- cancellationToken);
+ var primaryCallId = callIds[0];
+
+ foreach (var secondaryCallId in callIds.Skip(1))
+ {
+ var result = await ExecuteCallActionAsync(
+ primaryCallId,
+ "merge",
+ new Dictionary { ["target_call_id"] = secondaryCallId },
+ () => BuildCall(
+ primaryCallId,
+ CallState.Connected,
+ metadata: new Dictionary
+ {
+ ["isConference"] = true,
+ ["participantCount"] = callIds.Count,
+ }),
+ cancellationToken);
+
+ if (!result.Succeeded)
+ {
+ return result;
+ }
+ }
+
+ return TelephonyResult.Success(BuildCall(
+ primaryCallId,
+ CallState.Connected,
+ metadata: new Dictionary
+ {
+ ["isConference"] = true,
+ ["participantCount"] = callIds.Count,
+ }));
}
///
@@ -252,6 +442,15 @@ public Task AnswerAsync(CallReference call, CancellationToken c
public Task RejectAsync(CallReference call, CancellationToken cancellationToken = default)
=> ExecuteCallActionAsync(call?.CallId, "reject", body: null, () => BuildCall(call?.CallId, CallState.Disconnected, direction: CallDirection.Inbound), cancellationToken);
+ ///
+ public Task SendToVoicemailAsync(CallReference call, CancellationToken cancellationToken = default)
+ => ExecuteCallActionAsync(
+ call?.CallId,
+ "transfer",
+ new Dictionary { ["to_voicemail"] = true },
+ () => BuildCall(call?.CallId, CallState.Disconnected, direction: CallDirection.Inbound),
+ cancellationToken);
+
///
public async Task GetClientCredentialsAsync(CancellationToken cancellationToken = default)
{
@@ -270,6 +469,126 @@ public async Task GetClientCredentialsAsync(Cancella
};
}
+ ///
+ public async Task GetDirectoryAsync(CancellationToken cancellationToken = default)
+ {
+ var settings = await GetResolvedSettingsAsync();
+
+ if (!IsConfigured(settings))
+ {
+ return new TelephonyDirectoryResult
+ {
+ Succeeded = false,
+ Error = NotConfigured().Error,
+ };
+ }
+
+ var bearerToken = await GetBearerTokenAsync(settings, cancellationToken);
+
+ if (string.IsNullOrEmpty(bearerToken))
+ {
+ return new TelephonyDirectoryResult
+ {
+ Succeeded = false,
+ Error = NotConnected().Error,
+ };
+ }
+
+ try
+ {
+ var client = CreateClient(settings, bearerToken);
+ var entries = new List();
+ var visitedCursors = new HashSet(StringComparer.Ordinal);
+ string cursor = null;
+
+ do
+ {
+ var path = string.IsNullOrWhiteSpace(cursor)
+ ? "users"
+ : QueryHelpers.AddQueryString("users", "cursor", cursor);
+ using var response = await client.GetAsync(path, cancellationToken);
+
+ if (!response.IsSuccessStatusCode)
+ {
+ _logger.LogError("DialPad rejected a directory lookup with status code {StatusCode}.", response.StatusCode);
+
+ return new TelephonyDirectoryResult
+ {
+ Succeeded = false,
+ Error = S["DialPad could not load the directory."].Value,
+ };
+ }
+
+ await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
+ using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
+ var root = document.RootElement;
+
+ if (root.TryGetProperty("items", out var items) && items.ValueKind == JsonValueKind.Array)
+ {
+ foreach (var user in items.EnumerateArray())
+ {
+ var extension = ReadString(user, "extension");
+ var phoneNumber = ReadString(user, "phone_number");
+ var destination = !string.IsNullOrWhiteSpace(extension) ? extension : phoneNumber;
+
+ if (string.IsNullOrWhiteSpace(destination))
+ {
+ continue;
+ }
+
+ var firstName = ReadString(user, "first_name");
+ var lastName = ReadString(user, "last_name");
+ var displayName = string.Join(
+ " ",
+ new[] { firstName, lastName }.Where(value => !string.IsNullOrWhiteSpace(value)));
+
+ if (string.IsNullOrWhiteSpace(displayName))
+ {
+ displayName = ReadString(user, "email") ?? destination;
+ }
+
+ entries.Add(new TelephonyDirectoryEntry
+ {
+ Id = ReadScalarString(user, "id") ?? destination,
+ DisplayName = displayName,
+ Destination = destination,
+ Extension = extension,
+ PhoneNumber = phoneNumber,
+ Detail = ReadString(user, "email"),
+ });
+ }
+ }
+
+ cursor = ReadString(root, "cursor");
+
+ if (!string.IsNullOrWhiteSpace(cursor) && !visitedCursors.Add(cursor))
+ {
+ _logger.LogWarning("DialPad returned a repeated directory cursor; pagination stopped to avoid a lookup loop.");
+ break;
+ }
+ }
+ while (!string.IsNullOrWhiteSpace(cursor));
+
+ return new TelephonyDirectoryResult
+ {
+ Succeeded = true,
+ Entries = entries
+ .OrderBy(entry => entry.DisplayName, StringComparer.OrdinalIgnoreCase)
+ .ToList(),
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An error occurred while loading the DialPad directory.");
+
+ return new TelephonyDirectoryResult
+ {
+ Succeeded = false,
+ Error = S["DialPad could not load the directory."].Value,
+ };
+ }
+ }
+
///
public async Task GetAuthorizationUrlAsync(TelephonyAuthorizationContext context, CancellationToken cancellationToken = default)
{
@@ -536,7 +855,7 @@ private async Task ExecuteCallActionAsync(
if (!response.IsSuccessStatusCode)
{
- _logger.LogError("DialPad rejected the '{Action}' request for call {CallId} with status code {StatusCode}.", action, callId, response.StatusCode);
+ _logger.LogError("DialPad rejected the '{Action}' request for call {CallId} with status code {StatusCode}.", action, callId.SanitizeLogValue(), response.StatusCode);
return TelephonyResult.Failed(S["DialPad could not complete the requested operation."].Value);
}
@@ -547,7 +866,7 @@ private async Task ExecuteCallActionAsync(
{
_logger.LogError(ex, "An error occurred while performing the DialPad '{Action}' operation.", action);
- return TelephonyResult.Failed(S["DialPad could not complete the requested operation. Error: {0}", ex.Message].Value);
+ return TelephonyResult.Failed(S["DialPad could not complete the requested operation."].Value);
}
}
@@ -579,7 +898,8 @@ private static TelephonyCall BuildCall(
CallState state,
bool isMuted = false,
bool isOnHold = false,
- CallDirection direction = CallDirection.Outbound)
+ CallDirection direction = CallDirection.Outbound,
+ IDictionary metadata = null)
{
return new TelephonyCall
{
@@ -589,9 +909,100 @@ private static TelephonyCall BuildCall(
IsOnHold = isOnHold,
Direction = direction,
ProviderName = DialPadConstants.ProviderTechnicalName,
+ Metadata = metadata ?? new Dictionary(),
+ };
+ }
+
+ private static bool TryMapLookupState(string state, out CallState mapped)
+ {
+ mapped = state?.Trim().ToLowerInvariant() switch
+ {
+ "calling" or "dialing" or "connecting" or "preanswer" => CallState.Connecting,
+ "ringing" => CallState.Ringing,
+ "connected" or "active" => CallState.Connected,
+ "hold" or "on_hold" or "parked" => CallState.OnHold,
+ "hangup" or "ended" or "disconnected" or "completed" or "voicemail" => CallState.Disconnected,
+ "missed" or "no_answer" or "noanswer" => CallState.Failed,
+ "rejected" or "declined" or "busy" => CallState.Failed,
+ "canceled" or "cancelled" or "abandoned" => CallState.Disconnected,
+ _ => (CallState)(-1),
+ };
+
+ return Enum.IsDefined(mapped);
+ }
+
+ private static CallDirection ResolveDirection(string direction)
+ {
+ return string.Equals(direction?.Trim(), "inbound", StringComparison.OrdinalIgnoreCase)
+ ? CallDirection.Inbound
+ : CallDirection.Outbound;
+ }
+
+ private static string ReadString(JsonElement element, string propertyName)
+ {
+ return element.TryGetProperty(propertyName, out var value) && value.ValueKind == JsonValueKind.String
+ ? value.GetString()
+ : null;
+ }
+
+ private static string ReadScalarString(JsonElement element, string propertyName)
+ {
+ if (!element.TryGetProperty(propertyName, out var value))
+ {
+ return null;
+ }
+
+ return value.ValueKind switch
+ {
+ JsonValueKind.String => value.GetString(),
+ JsonValueKind.Number => value.GetRawText(),
+ _ => null,
+ };
+ }
+
+ private static bool ReadBoolean(JsonElement element, string propertyName)
+ {
+ if (!element.TryGetProperty(propertyName, out var value))
+ {
+ return false;
+ }
+
+ return value.ValueKind switch
+ {
+ JsonValueKind.True => true,
+ JsonValueKind.False => false,
+ JsonValueKind.String when bool.TryParse(value.GetString(), out var parsed) => parsed,
+ _ => false,
};
}
+ internal static DateTimeOffset? ReadDateTimeOffset(JsonElement element, string propertyName)
+ {
+ if (!element.TryGetProperty(propertyName, out var value))
+ {
+ return null;
+ }
+
+ if (value.ValueKind == JsonValueKind.String &&
+ DateTimeOffset.TryParse(
+ value.GetString(),
+ CultureInfo.InvariantCulture,
+ DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal,
+ out var parsed))
+ {
+ return parsed;
+ }
+
+ if (value.ValueKind == JsonValueKind.Number && value.TryGetInt64(out var unixValue))
+ {
+ return unixValue > 100_000_000_000
+ ? DateTimeOffset.FromUnixTimeMilliseconds(unixValue)
+ : DateTimeOffset.FromUnixTimeSeconds(unixValue);
+ }
+
+ return null;
+ }
+
private static DialPadAuthenticationType GetEffectiveAuthenticationType(DialPadSettings settings)
{
if (settings.AuthenticationType != DialPadAuthenticationType.NotConfigured)
diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/ViewModels/DialPadSettingsViewModel.cs b/src/Modules/CrestApps.OrchardCore.DialPad/ViewModels/DialPadSettingsViewModel.cs
index 3f84afb94..e2fa779b1 100644
--- a/src/Modules/CrestApps.OrchardCore.DialPad/ViewModels/DialPadSettingsViewModel.cs
+++ b/src/Modules/CrestApps.OrchardCore.DialPad/ViewModels/DialPadSettingsViewModel.cs
@@ -54,6 +54,11 @@ public class DialPadSettingsViewModel
///
public string UserId { get; set; }
+ ///
+ /// Gets or sets the DialPad webhook signing secret used to validate inbound call-event webhooks.
+ ///
+ public string WebhookSigningSecret { get; set; }
+
///
/// Gets or sets a value indicating whether an API key has already been saved.
///
@@ -65,4 +70,10 @@ public class DialPadSettingsViewModel
///
[BindNever]
public bool HasClientSecret { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether a webhook signing secret has already been saved.
+ ///
+ [BindNever]
+ public bool HasWebhookSigningSecret { get; set; }
}
diff --git a/src/Modules/CrestApps.OrchardCore.DialPad/Views/DialPadSettings.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.DialPad/Views/DialPadSettings.Edit.cshtml
index d09622967..98c4dd775 100644
--- a/src/Modules/CrestApps.OrchardCore.DialPad/Views/DialPadSettings.Edit.cshtml
+++ b/src/Modules/CrestApps.OrchardCore.DialPad/Views/DialPadSettings.Edit.cshtml
@@ -83,6 +83,15 @@
@T["The phone number presented to the recipient on outbound calls. Include a country code, for example +1 for the United States."]
+
+
+
+
+
+
+ @T["Used to validate inbound DialPad call-event webhooks posted to /api/dialpad/webhook/call. Configure a DialPad webhook with this secret so inbound calls create Contact Center activities and route to agents."]
+
+
diff --git a/src/Modules/CrestApps.OrchardCore.DncRegistry/BackgroundTasks/LocalDncImportBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.DncRegistry/BackgroundTasks/LocalDncImportBackgroundTask.cs
index f81e007c9..96657284f 100644
--- a/src/Modules/CrestApps.OrchardCore.DncRegistry/BackgroundTasks/LocalDncImportBackgroundTask.cs
+++ b/src/Modules/CrestApps.OrchardCore.DncRegistry/BackgroundTasks/LocalDncImportBackgroundTask.cs
@@ -21,12 +21,12 @@ public sealed class LocalDncImportBackgroundTask : IBackgroundTask
///
public Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToken cancellationToken)
- => ProcessEntriesAsync(serviceProvider, cancellationToken);
+ => ProcessEntriesAsync(serviceProvider, cancellationToken: cancellationToken);
internal static async Task ProcessEntriesAsync(
IServiceProvider serviceProvider,
- CancellationToken cancellationToken,
- string listId = null)
+ string listId = null,
+ CancellationToken cancellationToken = default)
{
var session = serviceProvider.GetRequiredService();
var distributedLock = serviceProvider.GetRequiredService();
diff --git a/src/Modules/CrestApps.OrchardCore.DncRegistry/Controllers/LocalDncRegistryAdminController.cs b/src/Modules/CrestApps.OrchardCore.DncRegistry/Controllers/LocalDncRegistryAdminController.cs
index ef98e53c1..143b5dfc3 100644
--- a/src/Modules/CrestApps.OrchardCore.DncRegistry/Controllers/LocalDncRegistryAdminController.cs
+++ b/src/Modules/CrestApps.OrchardCore.DncRegistry/Controllers/LocalDncRegistryAdminController.cs
@@ -362,7 +362,7 @@ private static void TriggerImportProcessing(string listId)
await HttpBackgroundJob.ExecuteAfterEndOfRequestAsync(
$"local-dnc-import-{listId}",
listId,
- static (scope, id) => BackgroundTasks.LocalDncImportBackgroundTask.ProcessEntriesAsync(scope.ServiceProvider, CancellationToken.None, id));
+ static (scope, id) => BackgroundTasks.LocalDncImportBackgroundTask.ProcessEntriesAsync(scope.ServiceProvider, id, CancellationToken.None));
});
}
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/BackgroundTasks/AutomatedActivitiesProcessorBackgroundTask.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/BackgroundTasks/AutomatedActivitiesProcessorBackgroundTask.cs
index dfd3f7d48..870509ac5 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/BackgroundTasks/AutomatedActivitiesProcessorBackgroundTask.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/BackgroundTasks/AutomatedActivitiesProcessorBackgroundTask.cs
@@ -17,14 +17,18 @@ namespace CrestApps.OrchardCore.Omnichannel.Managements.BackgroundTasks;
Schedule = "*/5 * * * *",
Description = "Processes omnichannel activities.",
LockTimeout = 5_000,
- LockExpiration = 90_000)]
+ LockExpiration = _leaseMilliseconds)]
///
/// Represents the automated activities processor background task.
///
public sealed class AutomatedActivitiesProcessorBackgroundTask : IBackgroundTask
{
+ private const int _leaseMilliseconds = 600_000;
private const int _batchSize = 100;
+ private const int _maxActivitiesPerInvocation = 1_000;
+ private const int _maxAttempts = 5;
+ private const int _retryDelayMinutes = 5;
///
/// Asynchronously performs the do work operation.
@@ -49,13 +53,28 @@ public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToke
var clock = serviceProvider.GetRequiredService();
var now = clock.UtcNow;
- long documentId = 0;
- var iterationCount = 0;
- await ExpireNoResponseActivitiesAsync(serviceProvider, session, now, logger, cancellationToken);
+ // Stop the run comfortably before the distributed-lock lease can expire. OrchardCore does not cancel a task
+ // when its lease elapses, so a run that outlived the lease while still alive could otherwise keep sending
+ // after another node had taken over. Stopping at a fraction of the lease keeps this node from handing an
+ // uncommitted backlog to a peer; the remaining work is picked up on the next scheduled invocation. The
+ // budget is charged against both the expiry pass and the send loop, and is re-checked per item, so a single
+ // slow send or expiry cannot overrun it by more than one item.
+ var deadline = now.AddMilliseconds(_leaseMilliseconds * 0.6);
+
+ await ExpireNoResponseActivitiesAsync(serviceProvider, session, clock, now, deadline, logger, cancellationToken);
+
+ // Commit the expiry pass on its own so its changes are durable regardless of what the processing loop does.
+ await session.SaveChangesAsync(cancellationToken);
+
+ long documentId = 0;
+ var processedCount = 0;
- while (true)
+ while (processedCount < _maxActivitiesPerInvocation && clock.UtcNow < deadline)
{
+ // Keyset pagination on the monotonically increasing document id. Combining an OFFSET skip with this
+ // cursor (as an earlier revision did) advanced the window twice per batch and silently skipped every
+ // other page of due activities.
var activities = await session.Query(x =>
(x.Status == ActivityStatus.NotStated || x.Status == ActivityStatus.Scheduled) &&
x.InteractionType == ActivityInteractionType.Automated &&
@@ -64,7 +83,6 @@ public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToke
x.DocumentId > documentId,
collection: OmnichannelConstants.CollectionName)
.OrderBy(x => x.DocumentId)
- .Skip(iterationCount++ * _batchSize)
.Take(_batchSize)
.ListAsync(cancellationToken);
@@ -75,6 +93,13 @@ public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToke
foreach (var activity in activities)
{
+ // Enforce the wall-clock budget per item, not just per batch. A batch that starts just under the
+ // deadline must not run a full page of additional sends past it, or the run could outlive its lease.
+ if (clock.UtcNow >= deadline)
+ {
+ break;
+ }
+
documentId = activity.Id;
try
@@ -83,61 +108,122 @@ public async Task DoWorkAsync(IServiceProvider serviceProvider, CancellationToke
await processor.StartAsync(activity, cancellationToken);
}
- catch (Exception ex)
+ catch (Exception ex) when (ex is not OperationCanceledException)
{
logger.LogError(ex, "An error occurred while processing the activity with id '{ActivityId}'", activity.ItemId);
+
+ // Persist a failure transition so the activity leaves the due set. Without it a permanently
+ // failing activity (for example a misconfigured automated inventory load) would re-match the due
+ // query on every invocation and consume one of the bounded per-invocation slots forever,
+ // eventually starving all healthy outbound work. ProcessingAttempts is an internal counter that
+ // starts at zero and is never projected from the contact-center work state, so this transition
+ // cannot be reset (and cannot corrupt the routing-owned Attempts field or its reports).
+ activity.ProcessingAttempts++;
+
+ if (activity.ProcessingAttempts >= _maxAttempts)
+ {
+ activity.Status = ActivityStatus.Failed;
+
+ if (string.IsNullOrWhiteSpace(activity.Notes))
+ {
+ activity.Notes = "The automated activity failed after exhausting the maximum number of processing attempts.";
+ }
+ }
+ else
+ {
+ activity.ScheduledUtc = now.AddMinutes(_retryDelayMinutes * activity.ProcessingAttempts);
+ }
}
await session.SaveAsync(activity, false, collection: OmnichannelConstants.CollectionName, cancellationToken);
+ processedCount++;
}
- await session.FlushAsync(cancellationToken);
+ // Commit each batch so processed activities are durably marked before the next batch is sent. These
+ // sends are not individually idempotent, so the commit boundary — together with the per-item wall-clock
+ // budget above that stops the run before its lease can expire — keeps a still-running node from handing
+ // an uncommitted backlog to a peer. If this node is instead killed mid-batch, only the single uncommitted
+ // in-flight batch (at most _batchSize) is re-sent by the node that acquires the lock next, rather than
+ // the whole backlog.
+ await session.SaveChangesAsync(cancellationToken);
}
-
- await session.SaveChangesAsync(cancellationToken);
}
private static async Task ExpireNoResponseActivitiesAsync(
IServiceProvider serviceProvider,
ISession session,
+ IClock clock,
DateTime now,
+ DateTime deadline,
ILogger logger,
CancellationToken cancellationToken)
{
var subjectFlowSettingsService = serviceProvider.GetRequiredService();
- var expiredActivities = await session.Query(x =>
- x.Status == ActivityStatus.AwaitingCustomerAnswer &&
- x.InteractionType == ActivityInteractionType.Automated &&
- x.ScheduledUtc <= now,
- collection: OmnichannelConstants.CollectionName)
- .Take(_batchSize)
- .ListAsync(cancellationToken);
+ var configuredFlowSettings = await subjectFlowSettingsService.GetConfiguredFlowSettingsAsync(cancellationToken);
- foreach (var activity in expiredActivities)
+ // Only subjects whose flow defines a no-response timeout can ever expire here. Restricting the query to those
+ // subject types keeps no-timeout conversations (which this pass never transitions) out of the candidate set
+ // entirely, so they can neither occupy the head of the query and starve activities that can actually expire
+ // nor have their user-visible ScheduledUtc rewritten with a sentinel to force them out.
+ var timeoutSubjectTypes = configuredFlowSettings
+ .Where(OmnichannelAutomationHelper.HasNoResponseTimeout)
+ .Select(settings => settings.SubjectContentType)
+ .Where(subjectContentType => !string.IsNullOrEmpty(subjectContentType))
+ .ToArray();
+
+ if (timeoutSubjectTypes.Length == 0)
{
- var flowSettings = await subjectFlowSettingsService.FindConfiguredFlowSettingsAsync(
- activity.SubjectContentType,
- cancellationToken);
+ return;
+ }
- if (!OmnichannelAutomationHelper.HasNoResponseTimeout(flowSettings))
- {
- continue;
- }
+ long documentId = 0;
+ var processedCount = 0;
- activity.Status = ActivityStatus.Failed;
+ while (processedCount < _maxActivitiesPerInvocation && clock.UtcNow < deadline)
+ {
+ // Keyset pagination so a large expiry backlog drains over successive batches without an OFFSET skip.
+ var expiredActivities = await session.Query(x =>
+ x.Status == ActivityStatus.AwaitingCustomerAnswer &&
+ x.InteractionType == ActivityInteractionType.Automated &&
+ x.ScheduledUtc <= now &&
+ x.SubjectContentType.IsIn(timeoutSubjectTypes) &&
+ x.DocumentId > documentId,
+ collection: OmnichannelConstants.CollectionName)
+ .OrderBy(x => x.DocumentId)
+ .Take(_batchSize)
+ .ListAsync(cancellationToken);
- if (string.IsNullOrWhiteSpace(activity.Notes))
+ if (!expiredActivities.Any())
{
- activity.Notes = "The automated SMS activity failed because the contact stopped responding.";
+ break;
}
- if (logger.IsEnabled(LogLevel.Information))
+ foreach (var activity in expiredActivities)
{
- logger.LogInformation("Automated activity '{ActivityId}' failed because the contact did not respond before the configured timeout.", activity.ItemId);
- }
+ // Share the run's wall-clock budget with the send loop so the expiry pass cannot consume it all.
+ if (clock.UtcNow >= deadline)
+ {
+ break;
+ }
- await session.SaveAsync(activity, false, collection: OmnichannelConstants.CollectionName, cancellationToken);
+ documentId = activity.Id;
+ processedCount++;
+
+ activity.Status = ActivityStatus.Failed;
+
+ if (string.IsNullOrWhiteSpace(activity.Notes))
+ {
+ activity.Notes = "The automated SMS activity failed because the contact stopped responding.";
+ }
+
+ if (logger.IsEnabled(LogLevel.Information))
+ {
+ logger.LogInformation("Automated activity '{ActivityId}' failed because the contact did not respond before the configured timeout.", activity.ItemId);
+ }
+
+ await session.SaveAsync(activity, false, collection: OmnichannelConstants.CollectionName, cancellationToken);
+ }
}
}
}
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs
index 9d1bd8ad4..33f8e7cd6 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ActivitiesController.cs
@@ -985,7 +985,7 @@ private async Task BulkAssignAsync(List activities, st
activity.AssignedToUtc = now;
activity.AssignmentStatus = ActivityAssignmentStatus.Assigned;
ClearReservationState(activity);
- activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(activity.InteractionType, hasAssignedUser: true);
+ ApplyInitialStatus(activity, hasAssignedUser: true);
await _omnichannelActivityManager.UpdateAsync(activity);
processedCount++;
@@ -1117,9 +1117,7 @@ private async Task BulkChangeSubjectAsync(List activit
activity.PreferredDestination = OmnichannelHelper.GetPreferredDestenation(contact, flowSettings.Channel);
}
- activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(
- activity.InteractionType,
- hasAssignedUser: !string.IsNullOrEmpty(activity.AssignedToId));
+ ApplyInitialStatus(activity, hasAssignedUser: !string.IsNullOrEmpty(activity.AssignedToId));
await _omnichannelActivityManager.UpdateAsync(activity);
processedCount++;
@@ -1176,7 +1174,7 @@ private async Task BulkChangeSourceAsync(
else if (string.IsNullOrEmpty(activity.AssignedToId))
{
activity.AssignmentStatus = ActivityAssignmentStatus.Available;
- activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(activity.InteractionType, hasAssignedUser: false);
+ ApplyInitialStatus(activity, hasAssignedUser: false);
}
await _omnichannelActivityManager.UpdateAsync(activity);
@@ -1226,7 +1224,7 @@ private async Task BulkChangeDialerProfileAsync(
else if (string.IsNullOrEmpty(activity.AssignedToId))
{
activity.AssignmentStatus = ActivityAssignmentStatus.Available;
- activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(activity.InteractionType, hasAssignedUser: false);
+ ApplyInitialStatus(activity, hasAssignedUser: false);
}
await _omnichannelActivityManager.UpdateAsync(activity);
@@ -1256,10 +1254,19 @@ private static void ResetAssignment(OmnichannelActivity activity)
activity.AssignedToUsername = null;
activity.AssignedToUtc = null;
activity.AssignmentStatus = ActivityAssignmentStatus.Available;
- activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(activity.InteractionType, hasAssignedUser: false);
+ ApplyInitialStatus(activity, hasAssignedUser: false);
ClearReservationState(activity);
}
+ private static void ApplyInitialStatus(OmnichannelActivity activity, bool hasAssignedUser)
+ {
+ activity.Status = OmnichannelAutomationHelper.GetInitialActivityStatus(activity.InteractionType, hasAssignedUser);
+
+ // Re-arming an existing activity to a due status starts a fresh automated-processing budget so a previously
+ // exhausted activity is not immediately re-failed by the background processor on its first transient error.
+ activity.ProcessingAttempts = 0;
+ }
+
private static void ClearReservationState(OmnichannelActivity activity)
{
activity.ReservationId = null;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/CampaignsController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/CampaignsController.cs
index a2f5c1d58..5467a82fb 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/CampaignsController.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/CampaignsController.cs
@@ -1,6 +1,6 @@
-using CrestApps.OrchardCore.Core.Validation;
-using CrestApps.Core.Services;
+using CrestApps.Core.Services;
using CrestApps.OrchardCore.Core.Models;
+using CrestApps.OrchardCore.Core.Validation;
using CrestApps.OrchardCore.Omnichannel.Core;
using CrestApps.OrchardCore.Omnichannel.Core.Models;
using Microsoft.AspNetCore.Authorization;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ChannelEndpointsController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ChannelEndpointsController.cs
index 6ab6273f4..4a1fc2062 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ChannelEndpointsController.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/ChannelEndpointsController.cs
@@ -1,6 +1,6 @@
-using CrestApps.OrchardCore.Core.Validation;
-using CrestApps.Core.Services;
+using CrestApps.Core.Services;
using CrestApps.OrchardCore.Core.Models;
+using CrestApps.OrchardCore.Core.Validation;
using CrestApps.OrchardCore.Omnichannel.Core;
using CrestApps.OrchardCore.Omnichannel.Core.Models;
using Microsoft.AspNetCore.Authorization;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/DispositionsController.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/DispositionsController.cs
index 7766a621d..299fa62f1 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/DispositionsController.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Controllers/DispositionsController.cs
@@ -1,6 +1,6 @@
-using CrestApps.OrchardCore.Core.Validation;
using CrestApps.Core.Services;
using CrestApps.OrchardCore.Core.Models;
+using CrestApps.OrchardCore.Core.Validation;
using CrestApps.OrchardCore.Omnichannel.Core;
using CrestApps.OrchardCore.Omnichannel.Core.Models;
using Microsoft.AspNetCore.Authorization;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityActionsDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityActionsDisplayDriver.cs
index e8b7c4109..90b72c510 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityActionsDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityActionsDisplayDriver.cs
@@ -1,4 +1,5 @@
using CrestApps.OrchardCore.Omnichannel.Core.Models;
+using CrestApps.OrchardCore.Omnichannel.Core.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels;
using Microsoft.AspNetCore.Mvc.Rendering;
@@ -6,7 +7,6 @@
using Microsoft.Extensions.Localization;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
-using CrestApps.OrchardCore.Omnichannel.Core.Services;
namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs
index d94f62729..971f237e6 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/BulkManageActivityFilterDisplayDriver.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using CrestApps.OrchardCore.Omnichannel.Core;
using CrestApps.OrchardCore.Omnichannel.Core.Models;
+using CrestApps.OrchardCore.Omnichannel.Core.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels;
using CrestApps.OrchardCore.Users;
@@ -15,7 +16,6 @@
using OrchardCore.Users.Models;
using YesSql;
using YesSql.Services;
-using CrestApps.OrchardCore.Omnichannel.Core.Services;
namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/ListOmnichannelActivityFilterDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/ListOmnichannelActivityFilterDisplayDriver.cs
index fcdf2ca43..513ffc89c 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/ListOmnichannelActivityFilterDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/ListOmnichannelActivityFilterDisplayDriver.cs
@@ -1,6 +1,7 @@
using System.Globalization;
using CrestApps.OrchardCore.Omnichannel.Core;
using CrestApps.OrchardCore.Omnichannel.Core.Models;
+using CrestApps.OrchardCore.Omnichannel.Core.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels;
using Microsoft.AspNetCore.Mvc.Rendering;
@@ -8,7 +9,6 @@
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
using OrchardCore.Modules;
-using CrestApps.OrchardCore.Omnichannel.Core.Services;
namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/NewActivitySubjectActionDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/NewActivitySubjectActionDisplayDriver.cs
index e40f33133..7cafe4eb9 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/NewActivitySubjectActionDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/NewActivitySubjectActionDisplayDriver.cs
@@ -1,6 +1,7 @@
using CrestApps.Core;
using CrestApps.OrchardCore.Omnichannel.Core;
using CrestApps.OrchardCore.Omnichannel.Core.Models;
+using CrestApps.OrchardCore.Omnichannel.Core.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels;
using CrestApps.OrchardCore.Users;
@@ -8,11 +9,9 @@
using Microsoft.Extensions.Localization;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
-using OrchardCore.Mvc.ModelBinding;
using OrchardCore.Users.Indexes;
using OrchardCore.Users.Models;
using YesSql;
-using CrestApps.OrchardCore.Omnichannel.Core.Services;
namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityDisplayDriver.cs
index ce19b5755..624d44be2 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelActivityDisplayDriver.cs
@@ -3,6 +3,7 @@
using CrestApps.Core.Services;
using CrestApps.OrchardCore.Omnichannel.Core;
using CrestApps.OrchardCore.Omnichannel.Core.Models;
+using CrestApps.OrchardCore.Omnichannel.Core.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels;
using CrestApps.OrchardCore.Users;
@@ -21,7 +22,6 @@
using OrchardCore.Users.Models;
using YesSql;
using IYesSqlSession = YesSql.ISession;
-using CrestApps.OrchardCore.Omnichannel.Core.Services;
namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelCampaignDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelCampaignDisplayDriver.cs
index a1157127c..95df16ef7 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelCampaignDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelCampaignDisplayDriver.cs
@@ -6,7 +6,6 @@
using OrchardCore;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
-using OrchardCore.Mvc.ModelBinding;
namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelDispositionDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelDispositionDisplayDriver.cs
index b1e79e187..6fc80fa72 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelDispositionDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/OmnichannelDispositionDisplayDriver.cs
@@ -4,7 +4,6 @@
using OrchardCore;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
-using OrchardCore.Mvc.ModelBinding;
namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/SubjectActionDisplayDriver.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/SubjectActionDisplayDriver.cs
index b8c963bbb..08fdc5792 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/SubjectActionDisplayDriver.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Drivers/SubjectActionDisplayDriver.cs
@@ -5,7 +5,6 @@
using Microsoft.Extensions.Localization;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.DisplayManagement.Views;
-using OrchardCore.Mvc.ModelBinding;
namespace CrestApps.OrchardCore.Omnichannel.Managements.Drivers;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityIndexMigrations.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityIndexMigrations.cs
index fe1e2e9f7..b00a51070 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityIndexMigrations.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Migrations/OmnichannelActivityIndexMigrations.cs
@@ -209,18 +209,26 @@ public async Task UpdateFrom4Async()
// drops are tolerant because MySQL commits each schema change on its own and writes this drop without
// IF EXISTS, so an attempt that stopped part-way would otherwise fail every activation from here on. A
// drop that genuinely fails is still reported, because the recreation below runs on the strict builder.
+ // Each index is dropped in its own alter so a swallowed failure of one — a re-run meeting an index a
+ // previous attempt already dropped — cannot suppress the drop of the next: the data layer runs every
+ // statement of a single alter under one try, so batching the drops would let the first failure strand
+ // the rest, and a surviving index would then make its own recreation below fail every activation.
var tolerantSchemaBuilder = new SchemaBuilder(
_store.Configuration,
SchemaBuilder.Transaction,
throwOnError: false);
- await tolerantSchemaBuilder.AlterIndexTableAsync(table =>
- {
- table.DropIndex("IDX_OmnichannelActivityMyActivities_DocumentId");
- table.DropIndex("IDX_OmnichannelActivityMyActivities_BatchLoading");
- table.DropIndex("IDX_OmnichannelActivity_Assignment");
- },
- collection: OmnichannelConstants.CollectionName);
+ await tolerantSchemaBuilder.AlterIndexTableAsync(
+ table => table.DropIndex("IDX_OmnichannelActivityMyActivities_DocumentId"),
+ collection: OmnichannelConstants.CollectionName);
+
+ await tolerantSchemaBuilder.AlterIndexTableAsync(
+ table => table.DropIndex("IDX_OmnichannelActivityMyActivities_BatchLoading"),
+ collection: OmnichannelConstants.CollectionName);
+
+ await tolerantSchemaBuilder.AlterIndexTableAsync(
+ table => table.DropIndex("IDX_OmnichannelActivity_Assignment"),
+ collection: OmnichannelConstants.CollectionName);
await IndexColumnRebuild.RebuildAsEnumColumnAsync(
SchemaBuilder,
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs
index bf2696582..8929758a6 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Startup.cs
@@ -6,22 +6,34 @@
using CrestApps.OrchardCore.Omnichannel.Core.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.Drivers;
using CrestApps.OrchardCore.Omnichannel.Managements.Handlers;
+using CrestApps.OrchardCore.Omnichannel.Managements.Indexes;
+using CrestApps.OrchardCore.Omnichannel.Managements.Migrations;
using CrestApps.OrchardCore.Omnichannel.Managements.Reports;
using CrestApps.OrchardCore.Omnichannel.Managements.Services;
using CrestApps.OrchardCore.Omnichannel.Managements.ViewModels;
using CrestApps.OrchardCore.PhoneNumbers.Core;
using CrestApps.OrchardCore.Reports;
using CrestApps.OrchardCore.Reports.Models;
+using CrestApps.OrchardCore.Users;
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Localization;
using OrchardCore.ContentManagement;
using OrchardCore.ContentManagement.Display.ContentDisplay;
using OrchardCore.Contents.Services;
using OrchardCore.ContentTypes.Editors;
+using OrchardCore.ContentTypes.Events;
+using OrchardCore.Data;
+using OrchardCore.Data.Migration;
using OrchardCore.DisplayManagement;
using OrchardCore.DisplayManagement.Handlers;
using OrchardCore.Modules;
using OrchardCore.Navigation;
+using OrchardCore.Security.Permissions;
+using OrchardCore.Users;
namespace CrestApps.OrchardCore.Omnichannel.Managements;
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelCampaignGroup.Edit.cshtml b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelCampaignGroup.Edit.cshtml
new file mode 100644
index 000000000..ad1bf3a8c
--- /dev/null
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Managements/Views/OmnichannelCampaignGroup.Edit.cshtml
@@ -0,0 +1 @@
+@await DisplayAsync(Model.Content)
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Endpoints/TwilioWebhookEndpoint.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Endpoints/TwilioWebhookEndpoint.cs
index 9cab5cc76..d515d92b0 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Endpoints/TwilioWebhookEndpoint.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Endpoints/TwilioWebhookEndpoint.cs
@@ -55,29 +55,24 @@ private static async Task HandleAsync(
var request = context.Request;
- var requestUrl = $"{request.Scheme}://{request.Host}{request.Path}{request.QueryString}";
+ var form = request.HasFormContentType
+ ? await request.ReadFormAsync(context.RequestAborted)
+ : null;
- Dictionary parameters = null;
- IFormCollection form = null;
+ var site = await siteService.GetSiteSettingsAsync();
- if (request.HasFormContentType)
- {
- form = await request.ReadFormAsync(context.RequestAborted).ConfigureAwait(false);
-
- parameters = form.ToDictionary(p => p.Key, p => p.Value.ToString());
- }
-
- var validator = new TwillioRequestValidator(authToken);
-
- if (!request.Headers.TryGetValue("X-Twilio-Signature", out var signature) ||
- !validator.Validate(requestUrl, parameters, signature.First()))
+ // Reuse the Event Grid endpoint's tested signature validator so both inbound paths honour the operator's
+ // configured public base URL and path base. Building the signed URL from the raw request scheme/host/path
+ // (as this endpoint previously did) omits the path base and rejects genuine deliveries behind a
+ // TLS-terminating proxy.
+ if (!TwilioEventGridEndpoint.IsRequestValid(context, authToken, site.BaseUrl, logger))
{
logger.LogWarning("Unauthorized Twilio request.");
return TypedResults.Forbid();
}
- form ??= await context.Request.ReadFormAsync(context.RequestAborted);
+ form ??= await request.ReadFormAsync(context.RequestAborted);
var from = form["From"].ToString();
var to = form["To"].ToString();
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs
index 06c8dd0af..cf35da1ee 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Handlers/SmsOmnichannelEventHandler.cs
@@ -43,7 +43,7 @@ internal sealed class SmsOmnichannelEventHandler : IOmnichannelEventHandler
private readonly IAIProfileManager _profileManager;
private readonly ITemplateService _aiTemplateService;
private readonly IOmnichannelChannelEndpointManager _channelEndpointsManager;
- private readonly ICatalog _flowSettingsCatalog;
+ private readonly ISubjectFlowSettingsService _subjectFlowSettingsService;
private readonly IContentManager _contentManager;
private readonly IClock _clock;
private readonly ISession _session;
@@ -68,7 +68,7 @@ internal sealed class SmsOmnichannelEventHandler : IOmnichannelEventHandler
/// The AI profile manager.
/// The ai template service.
/// The channel endpoints manager.
- /// The subject flow settings catalog.
+ /// The subject flow settings service.
/// The content manager.
/// The clock.
/// The session.
@@ -87,7 +87,7 @@ public SmsOmnichannelEventHandler(
IAIProfileManager profileManager,
ITemplateService aiTemplateService,
IOmnichannelChannelEndpointManager channelEndpointsManager,
- ICatalog flowSettingsCatalog,
+ ISubjectFlowSettingsService subjectFlowSettingsService,
IContentManager contentManager,
IClock clock,
ISession session,
@@ -106,7 +106,7 @@ public SmsOmnichannelEventHandler(
_profileManager = profileManager;
_aiTemplateService = aiTemplateService;
_channelEndpointsManager = channelEndpointsManager;
- _flowSettingsCatalog = flowSettingsCatalog;
+ _subjectFlowSettingsService = subjectFlowSettingsService;
_contentManager = contentManager;
_clock = clock;
_session = session;
@@ -423,34 +423,41 @@ await _promptStore.CreateAsync(new AIChatSessionPrompt
if (result.Result.Concluded)
{
- var clock = scope.ServiceProvider.GetRequiredService();
- var executor = scope.ServiceProvider.GetRequiredService();
+ if (flowSettings.RequireDisposition && string.IsNullOrEmpty(result.Result.DispositionId))
+ {
+ _logger.LogWarning("The automated SMS conversation for Activity {ActivityId} reported concluded without a disposition, but its subject flow requires one. The activity is left open so the required-disposition policy is not bypassed; it will close through the existing no-response timeout or opt-out paths.", activity.ItemId.SanitizeLogValue());
+ }
+ else
+ {
+ var clock = scope.ServiceProvider.GetRequiredService();
+ var executor = scope.ServiceProvider.GetRequiredService();
- omnichannelActivity ??= await store.FindByIdAsync(activity.ItemId);
+ omnichannelActivity ??= await store.FindByIdAsync(activity.ItemId);
- omnichannelActivity.Status = ActivityStatus.Completed;
+ omnichannelActivity.Status = ActivityStatus.Completed;
- omnichannelActivity.CompletedUtc = clock.UtcNow;
+ omnichannelActivity.CompletedUtc = clock.UtcNow;
- omnichannelActivity.DispositionId = result.Result.DispositionId;
+ omnichannelActivity.DispositionId = result.Result.DispositionId;
- omnichannelActivity.CompletedById = omnichannelActivity.AssignedToId;
- omnichannelActivity.CompletedByUsername = omnichannelActivity.AssignedToUsername;
+ omnichannelActivity.CompletedById = omnichannelActivity.AssignedToId;
+ omnichannelActivity.CompletedByUsername = omnichannelActivity.AssignedToUsername;
- await store.UpdateAsync(omnichannelActivity);
+ await store.UpdateAsync(omnichannelActivity);
- subject ??= activity.Subject ?? await contentManager.NewAsync(activity.SubjectContentType);
- contact ??= await contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest);
+ subject ??= activity.Subject ?? await contentManager.NewAsync(activity.SubjectContentType);
+ contact ??= await contentManager.GetAsync(activity.ContactContentItemId, VersionOptions.Latest);
- var dispositionObj = dispositions.FirstOrDefault(d => d.ItemId == result.Result.DispositionId);
+ var dispositionObj = dispositions.FirstOrDefault(d => d.ItemId == result.Result.DispositionId);
- await executor.ExecuteAsync(new SubjectActionExecutionContext
- {
- Activity = omnichannelActivity,
- Contact = contact,
- Subject = subject,
- Disposition = dispositionObj,
- });
+ await executor.ExecuteAsync(new SubjectActionExecutionContext
+ {
+ Activity = omnichannelActivity,
+ Contact = contact,
+ Subject = subject,
+ Disposition = dispositionObj,
+ });
+ }
}
}
});
@@ -476,10 +483,7 @@ private async Task FindFlowSettingsAsync(
return null;
}
- var flowSettings = await _flowSettingsCatalog.GetAllAsync(cancellationToken);
-
- return flowSettings.FirstOrDefault(settings =>
- string.Equals(settings.SubjectContentType, subjectContentType, StringComparison.OrdinalIgnoreCase));
+ return await _subjectFlowSettingsService.FindConfiguredFlowSettingsAsync(subjectContentType, cancellationToken);
}
private async Task ApplySmsOptOutAsync(
diff --git a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs
index bd5ccabad..5c9769b8d 100644
--- a/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs
+++ b/src/Modules/CrestApps.OrchardCore.Omnichannel.Sms/Services/SmsOmnichannelProcessor.cs
@@ -27,7 +27,7 @@ public sealed class SmsOmnichannelProcessor : IOmnichannelProcessor
private readonly IAIChatSessionPromptStore _promptStore;
private readonly IAIProfileManager _profileManager;
private readonly ICatalog _campaignCatalog;
- private readonly ICatalog _flowSettingsCatalog;
+ private readonly ISubjectFlowSettingsService _subjectFlowSettingsService;
private readonly ICatalog _channelEndpointCatalog;
private readonly ISmsService _smsService;
private readonly ILiquidTemplateManager _liquidTemplateManager;
@@ -43,7 +43,7 @@ public sealed class SmsOmnichannelProcessor : IOmnichannelProcessor
/// The prompt store.
/// The AI profile manager.
/// The campaign catalog.
- /// The subject flow settings catalog.
+ /// The subject flow settings service.
/// The channel endpoint catalog.
/// The sms service.
/// The liquid template manager.
@@ -55,7 +55,7 @@ public SmsOmnichannelProcessor(
IAIChatSessionPromptStore promptStore,
IAIProfileManager profileManager,
ICatalog campaignCatalog,
- ICatalog flowSettingsCatalog,
+ ISubjectFlowSettingsService subjectFlowSettingsService,
ICatalog channelEndpointCatalog,
ISmsService smsService,
ILiquidTemplateManager liquidTemplateManager,
@@ -67,7 +67,7 @@ public SmsOmnichannelProcessor(
_promptStore = promptStore;
_profileManager = profileManager;
_campaignCatalog = campaignCatalog;
- _flowSettingsCatalog = flowSettingsCatalog;
+ _subjectFlowSettingsService = subjectFlowSettingsService;
_channelEndpointCatalog = channelEndpointCatalog;
_smsService = smsService;
_liquidTemplateManager = liquidTemplateManager;
@@ -220,9 +220,6 @@ private async Task FindFlowSettingsAsync(
return null;
}
- var flowSettings = await _flowSettingsCatalog.GetAllAsync(cancellationToken);
-
- return flowSettings.FirstOrDefault(settings =>
- string.Equals(settings.SubjectContentType, subjectContentType, StringComparison.OrdinalIgnoreCase));
+ return await _subjectFlowSettingsService.FindConfiguredFlowSettingsAsync(subjectContentType, cancellationToken);
}
}
diff --git a/src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/Startup.cs b/src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/Startup.cs
index bbc107f33..0b0de51b9 100644
--- a/src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/Startup.cs
+++ b/src/Modules/CrestApps.OrchardCore.PhoneNumbers.Verifications/Startup.cs
@@ -2,13 +2,13 @@
using CrestApps.OrchardCore.PhoneNumbers.Core;
using CrestApps.OrchardCore.PhoneNumbers.Core.Models;
using CrestApps.OrchardCore.PhoneNumbers.Core.Services;
-using CrestApps.OrchardCore.PhoneNumbers.Verifications.Reports;
using CrestApps.OrchardCore.PhoneNumbers.Verifications.BackgroundTasks;
using CrestApps.OrchardCore.PhoneNumbers.Verifications.Drivers;
using CrestApps.OrchardCore.PhoneNumbers.Verifications.Handlers;
using CrestApps.OrchardCore.PhoneNumbers.Verifications.Indexes;
using CrestApps.OrchardCore.PhoneNumbers.Verifications.Migrations;
using CrestApps.OrchardCore.PhoneNumbers.Verifications.Models;
+using CrestApps.OrchardCore.PhoneNumbers.Verifications.Reports;
using CrestApps.OrchardCore.PhoneNumbers.Verifications.Services;
using CrestApps.OrchardCore.Reports;
using Microsoft.Extensions.DependencyInjection;
diff --git a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js
index e8fea5447..7d83d6535 100644
--- a/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js
+++ b/src/Modules/CrestApps.OrchardCore.Telephony/Assets/js/soft-phone.js
@@ -19,7 +19,15 @@
Transfer: 1 << 5,
Merge: 1 << 6,
SendDigits: 1 << 7,
- ReceiveCalls: 1 << 8
+ ReceiveCalls: 1 << 8,
+ Voicemail: 1 << 9,
+ Directory: 1 << 10
+ };
+
+ var AUDIO_MODES = {
+ None: 0,
+ Browser: 1,
+ ExternalDevice: 2
};
var STATE_NAMES = ['Idle', 'Connecting', 'Ringing', 'Connected', 'OnHold', 'Disconnected', 'Failed'];
@@ -61,6 +69,270 @@
return element.innerHTML;
}
+ function buildRegistrationConfigUrl(config) {
+ if (config.registrationConfigUrl) {
+ return config.registrationConfigUrl;
+ }
+
+ var parts = window.location.pathname.split('/').filter(function (part) {
+ return !!part;
+ });
+ var adminPrefix = parts.length ? parts[0] : 'Admin';
+
+ return '/' + adminPrefix + '/contact-center/agent/soft-phone/registration-config';
+ }
+
+ function fetchRegistrationConfig(config) {
+ return fetch(buildRegistrationConfigUrl(config), {
+ method: 'GET',
+ credentials: 'same-origin',
+ headers: {
+ Accept: 'application/json'
+ }
+ }).then(function (response) {
+ if (!response.ok) {
+ throw new Error('The browser media registration configuration is unavailable.');
+ }
+
+ return response.json();
+ });
+ }
+
+ function createRemoteStreamSink(setRemoteStream) {
+ var remoteStream = new MediaStream();
+
+ return {
+ stream: remoteStream,
+ addTrack: function (track) {
+ remoteStream.addTrack(track);
+ setRemoteStream(remoteStream);
+ },
+ clear: function () {
+ remoteStream.getTracks().forEach(function (track) {
+ remoteStream.removeTrack(track);
+ track.stop();
+ });
+ setRemoteStream(null);
+ }
+ };
+ }
+
+ function createBrowserMediaAdapterRegistry(rootElement, config) {
+ var adapters = {};
+
+ /*
+ * IBrowserMediaAdapter contract:
+ * adapter(context) -> Promise/session
+ * context: { config, credentials, localStream, remoteAudioElement, setRemoteStream, showError }
+ * session: { handleCallState(call), dispose() }
+ *
+ * The registry is intentionally scoped to this soft-phone instance/page. Providers add server
+ * contributors through shell DI; the browser does not expose a global adapter registry. A provider
+ * that ships its own browser media stack registers it on the instance through
+ * `registerMediaAdapter`, so one page can host adapters from different providers without a
+ * process-wide registry that any script could silently overwrite.
+ */
+ adapters.sipjs = createSipJsBrowserMediaAdapter(rootElement, config);
+
+ return adapters;
+ }
+
+ function createSipJsBrowserMediaAdapter(rootElement, widgetConfig) {
+ return function (context) {
+ var sip = window.SIP;
+
+ if (!sip || typeof sip.UserAgent !== 'function') {
+ return Promise.reject(new Error('SIP.js is required for the configured browser audio adapter.'));
+ }
+
+ return fetchRegistrationConfig(widgetConfig).then(function (registrationConfig) {
+ return createSipJsSession(sip, context, registrationConfig);
+ });
+ };
+ }
+
+ function createSipJsSession(sip, context, registrationConfig) {
+ var signaling = registrationConfig.signaling || {};
+ var credential = registrationConfig.credential || {};
+ var ice = registrationConfig.ice || {};
+ var media = registrationConfig.media || {};
+ var remoteSink = createRemoteStreamSink(context.setRemoteStream);
+ var peerConnection = null;
+ var activeSession = null;
+ var registerer = null;
+ var disposed = false;
+
+ if (!signaling.webSocketUrl || !signaling.sipUri || !signaling.authorizationUser || !credential.value) {
+ return Promise.reject(new Error('The browser media registration configuration is incomplete.'));
+ }
+
+ function getSessionDescriptionHandler(session) {
+ return session && session.sessionDescriptionHandler
+ ? session.sessionDescriptionHandler
+ : null;
+ }
+
+ function attachPeerConnection(session) {
+ var handler = getSessionDescriptionHandler(session);
+
+ if (!handler || !handler.peerConnection || peerConnection === handler.peerConnection) {
+ return;
+ }
+
+ peerConnection = handler.peerConnection;
+ context.localStream.getTracks().forEach(function (track) {
+ var alreadyAdded = peerConnection.getSenders().some(function (sender) {
+ return sender.track === track;
+ });
+
+ if (!alreadyAdded) {
+ peerConnection.addTrack(track, context.localStream);
+ }
+ });
+
+ peerConnection.getReceivers().forEach(function (receiver) {
+ if (receiver.track) {
+ remoteSink.addTrack(receiver.track);
+ }
+ });
+ peerConnection.addEventListener('track', function (event) {
+ if (event.track) {
+ remoteSink.addTrack(event.track);
+ }
+ });
+ }
+
+ function wireSession(session) {
+ activeSession = session;
+ attachPeerConnection(session);
+
+ if (session.stateChange && typeof session.stateChange.addListener === 'function') {
+ session.stateChange.addListener(function () {
+ attachPeerConnection(session);
+ });
+ }
+ }
+
+ function setMicrophoneEnabled(enabled) {
+ context.localStream.getAudioTracks().forEach(function (track) {
+ track.enabled = enabled;
+ });
+ }
+
+ function requestHold(hold) {
+ if (!activeSession || typeof activeSession.invite !== 'function') {
+ return Promise.resolve();
+ }
+
+ var modifiers = hold && sip.Web && sip.Web.holdModifier
+ ? [sip.Web.holdModifier]
+ : [];
+
+ return Promise.resolve(activeSession.invite({ requestDelegate: {}, sessionDescriptionHandlerModifiers: modifiers })).catch(function () { });
+ }
+
+ function terminateSession() {
+ if (!activeSession) {
+ return Promise.resolve();
+ }
+
+ if (typeof activeSession.bye === 'function') {
+ return Promise.resolve(activeSession.bye()).catch(function () { });
+ }
+
+ if (typeof activeSession.dispose === 'function') {
+ return Promise.resolve(activeSession.dispose()).catch(function () { });
+ }
+
+ return Promise.resolve();
+ }
+
+ var userAgent = new sip.UserAgent({
+ uri: sip.UserAgent.makeURI(signaling.sipUri),
+ displayName: signaling.displayName || '',
+ authorizationUsername: signaling.authorizationUser,
+ authorizationPassword: credential.value,
+ transportOptions: {
+ server: signaling.webSocketUrl
+ },
+ sessionDescriptionHandlerFactoryOptions: {
+ constraints: {
+ audio: true,
+ video: false
+ },
+ peerConnectionConfiguration: {
+ iceServers: ice.iceServers || [],
+ iceTransportPolicy: ice.iceTransportPolicy || 'all'
+ }
+ },
+ delegate: {
+ onInvite: function (invitation) {
+ wireSession(invitation);
+ Promise.resolve(invitation.accept({
+ sessionDescriptionHandlerOptions: {
+ constraints: {
+ audio: true,
+ video: false
+ }
+ }
+ })).then(function () {
+ attachPeerConnection(invitation);
+ }).catch(function (error) {
+ context.showError(error && error.message ? error.message : String(error));
+ });
+ }
+ }
+ });
+
+ registerer = new sip.Registerer(userAgent, {
+ expires: Math.max(30, Math.floor((Date.parse(credential.expiresAtUtc) - Date.now()) / 1000))
+ });
+
+ return userAgent.start().then(function () {
+ return registerer.register();
+ }).then(function () {
+ return {
+ providerConfig: registrationConfig,
+ mediaCodecs: media.codecs || [],
+ handleCallState: function (call) {
+ var stateName = normalizeState(call && call.state);
+
+ if (stateName === 'Disconnected' || stateName === 'Failed' || !call) {
+ return terminateSession();
+ }
+
+ setMicrophoneEnabled(stateName === 'Connected' && !call.isMuted);
+
+ if (stateName === 'OnHold') {
+ return requestHold(true);
+ }
+
+ if (stateName === 'Connected') {
+ return requestHold(false);
+ }
+
+ return Promise.resolve();
+ },
+ dispose: function () {
+ if (disposed) {
+ return Promise.resolve();
+ }
+
+ disposed = true;
+ remoteSink.clear();
+
+ return terminateSession()
+ .then(function () {
+ return registerer ? registerer.unregister().catch(function () { }) : null;
+ })
+ .then(function () {
+ return userAgent.stop().catch(function () { });
+ });
+ }
+ };
+ });
+ }
+
function clamp(value, min, max) {
return Math.min(Math.max(value, min), max);
}
@@ -69,6 +341,80 @@
return typeof value === 'number' && isFinite(value);
}
+ function normalizeDialNumber(value) {
+ var input = String(value || '').trim();
+ var hasInternationalPrefix = input.charAt(0) === '+';
+ var digits = input.replace(/\D/g, '');
+
+ return (hasInternationalPrefix ? '+' : '') + digits;
+ }
+
+ function formatNanpNumber(digits, international) {
+ var national = international ? digits.substring(1) : digits;
+ var formatted = '';
+
+ if (international) {
+ formatted = '+1';
+ }
+
+ if (national.length > 0) {
+ formatted += (international ? ' ' : '') + '(' + national.substring(0, 3);
+ }
+
+ if (national.length >= 3) {
+ formatted += ')';
+ }
+
+ if (national.length > 3) {
+ formatted += ' ' + national.substring(3, 6);
+ }
+
+ if (national.length > 6) {
+ formatted += '-' + national.substring(6, 10);
+ }
+
+ return formatted;
+ }
+
+ function formatInternationalNumber(digits) {
+ if (!digits) {
+ return '+';
+ }
+
+ var countryCodeLength = digits.length > 10 ? Math.min(3, digits.length - 10) : Math.min(2, digits.length);
+ var countryCode = digits.substring(0, countryCodeLength);
+ var national = digits.substring(countryCodeLength);
+ var groups = [];
+
+ while (national.length > 4) {
+ groups.push(national.substring(0, 3));
+ national = national.substring(3);
+ }
+
+ if (national) {
+ groups.push(national);
+ }
+
+ return '+' + countryCode + (groups.length ? ' ' + groups.join(' ') : '');
+ }
+
+ function formatPhoneNumber(value) {
+ var normalized = normalizeDialNumber(value);
+ var international = normalized.charAt(0) === '+';
+ var digits = normalized.replace(/\D/g, '');
+
+ if (!international && digits.length < 7) {
+ return digits;
+ }
+
+ if ((!international && digits.length <= 10) ||
+ (international && digits.charAt(0) === '1' && digits.length <= 11)) {
+ return formatNanpNumber(digits, international);
+ }
+
+ return international ? formatInternationalNumber(digits) : digits;
+ }
+
function createSoftPhone(rootElement, options) {
options = options || {};
@@ -76,6 +422,7 @@
var strings = config.strings || {};
var capabilities = config.capabilities || 0;
var storageKey = (config.storageKey || 'telephony-soft-phone') + '-layout';
+ var mediaAdapters = createBrowserMediaAdapterRegistry(rootElement, config);
var signalRFactory = options.signalRFactory || (typeof signalR !== 'undefined' ? signalR : null);
@@ -87,8 +434,9 @@
close: rootElement.querySelector('[data-telephony-close]'),
status: rootElement.querySelector('[data-telephony-status]'),
number: rootElement.querySelector('[data-telephony-number]'),
- peer: rootElement.querySelector('[data-telephony-peer]'),
error: rootElement.querySelector('[data-telephony-error]'),
+ activeCalls: rootElement.querySelector('[data-telephony-active-calls]'),
+ activeCallsList: rootElement.querySelector('[data-telephony-active-calls-list]'),
keys: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-key]')),
dial: rootElement.querySelector('[data-telephony-dial]'),
hold: rootElement.querySelector('[data-telephony-hold]'),
@@ -96,8 +444,18 @@
mute: rootElement.querySelector('[data-telephony-mute]'),
unmute: rootElement.querySelector('[data-telephony-unmute]'),
transfer: rootElement.querySelector('[data-telephony-transfer]'),
+ transferIcon: rootElement.querySelector('[data-telephony-transfer-icon]'),
+ transferLabel: rootElement.querySelector('[data-telephony-transfer-label]'),
+ transferPanel: rootElement.querySelector('[data-telephony-transfer-panel]'),
+ keypadPanel: rootElement.querySelector('[data-telephony-keypad-panel]'),
+ transferInput: rootElement.querySelector('[data-telephony-transfer-input]'),
+ transferCancel: rootElement.querySelector('[data-telephony-transfer-cancel]'),
+ transferConfirm: rootElement.querySelector('[data-telephony-transfer-confirm]'),
+ directory: rootElement.querySelector('[data-telephony-directory]'),
+ directoryList: rootElement.querySelector('[data-telephony-directory-list]'),
merge: rootElement.querySelector('[data-telephony-merge]'),
hangup: rootElement.querySelector('[data-telephony-hangup]'),
+ hangupAll: rootElement.querySelector('[data-telephony-hangup-all]'),
connectPanel: rootElement.querySelector('[data-telephony-connect-panel]'),
connect: rootElement.querySelector('[data-telephony-connect]'),
unavailable: rootElement.querySelector('[data-telephony-unavailable]'),
@@ -106,17 +464,41 @@
history: rootElement.querySelector('[data-telephony-history]'),
historyList: rootElement.querySelector('[data-telephony-history-list]'),
footer: rootElement.querySelector('[data-telephony-footer]'),
- tabs: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-tab]'))
+ tabs: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-tab]')),
+ views: Array.prototype.slice.call(rootElement.querySelectorAll('[data-telephony-view]')),
+ incoming: rootElement.querySelector('[data-telephony-incoming]'),
+ incomingCaller: rootElement.querySelector('[data-telephony-incoming-caller]'),
+ incomingQueue: rootElement.querySelector('[data-telephony-incoming-queue]'),
+ incomingCards: rootElement.querySelector('[data-telephony-incoming-cards]'),
+ incomingAnswer: rootElement.querySelector('[data-telephony-incoming-answer]'),
+ incomingVoicemail: rootElement.querySelector('[data-telephony-incoming-voicemail]'),
+ incomingIgnore: rootElement.querySelector('[data-telephony-incoming-ignore]'),
+ remoteAudio: rootElement.querySelector('[data-telephony-remote-audio]')
};
var connection = null;
var currentCall = null;
+ var activeCalls = {};
+ var conferenceSelections = {};
+ var directoryEntries = [];
+ var transferOpen = false;
+ var numberIsCallDisplay = false;
+ var callStateRevision = 0;
+ var incomingContext = null;
+ var incomingHandled = false;
+ var incomingAcceptPending = false;
+ var incomingExpiryTimer = null;
var requiresAuthentication = false;
var isConnected = false;
var isAvailable = false;
+ var connectionStatusResolved = false;
var authenticationScheme = null;
var activeTab = 'keypad';
+ var activeCommand = null;
var suppressToggleClick = false;
+ var browserAudioPromise = null;
+ var browserAudioSession = null;
+ var localAudioStream = null;
function has(capability) {
return (capabilities & capability) === capability;
@@ -148,142 +530,427 @@
}
}
- function statusTextForState(stateName) {
- var key = stateName.charAt(0).toLowerCase() + stateName.slice(1);
+ function isBrowserAudioEnabled() {
+ return config.audioMode === AUDIO_MODES.Browser && !!config.browserMediaAdapterName;
+ }
- return strings[key] || stateName;
+ function stopLocalAudioStream() {
+ if (!localAudioStream) {
+ return;
+ }
+
+ localAudioStream.getTracks().forEach(function (track) {
+ track.stop();
+ });
+ localAudioStream = null;
}
- // ---- Layout persistence and dragging ----
+ function releaseBrowserAudio() {
+ browserAudioPromise = null;
- function loadLayout() {
- try {
- return JSON.parse(localStorage.getItem(storageKey)) || {};
- } catch (e) {
- return {};
+ if (browserAudioSession && typeof browserAudioSession.dispose === 'function') {
+ Promise.resolve(browserAudioSession.dispose()).catch(function () { });
}
- }
- function saveLayout(patch) {
- try {
- var layout = loadLayout();
- Object.assign(layout, patch);
- localStorage.setItem(storageKey, JSON.stringify(layout));
- } catch (e) {
- // Ignore storage errors (for example private browsing).
+ browserAudioSession = null;
+ stopLocalAudioStream();
+
+ if (dom.remoteAudio) {
+ dom.remoteAudio.srcObject = null;
}
}
- function applyRootPosition(left, top) {
- rootElement.style.left = left + 'px';
- rootElement.style.top = top + 'px';
- rootElement.style.right = 'auto';
- rootElement.style.bottom = 'auto';
+ function setRemoteAudioStream(stream) {
+ if (!dom.remoteAudio) {
+ return;
+ }
+
+ dom.remoteAudio.srcObject = stream || null;
+
+ if (stream && typeof dom.remoteAudio.play === 'function') {
+ Promise.resolve(dom.remoteAudio.play()).catch(function () { });
+ }
}
- function getAvailablePositionRange() {
- var toggleRect = rootElement.getBoundingClientRect();
- var toggleWidth = toggleRect.width || 56;
- var toggleHeight = toggleRect.height || 56;
- var margin = 8;
+ function ensureBrowserAudio() {
+ if (!isBrowserAudioEnabled()) {
+ return Promise.resolve(null);
+ }
- // Keep the toggle on screen so the widget can be dragged to any edge, including the far
- // right and over other widgets such as the AI chat widget.
- var maxLeft = Math.max(margin, window.innerWidth - toggleWidth - margin);
- var maxTop = Math.max(margin, window.innerHeight - toggleHeight - margin);
- var minLeft = margin;
- var minTop = margin;
+ if (browserAudioSession) {
+ return Promise.resolve(browserAudioSession);
+ }
- if (dom.panel && !dom.panel.hidden) {
- var panelRect = dom.panel.getBoundingClientRect();
- var panelWidth = panelRect.width || toggleWidth;
- var panelHeight = panelRect.height || 0;
+ if (browserAudioPromise) {
+ return browserAudioPromise;
+ }
- // The panel is anchored to the right of the toggle and floats above it, so it extends
- // to the left and up. Keep the panel within the viewport so its header stays grabbable.
- minLeft = Math.min(maxLeft, Math.max(margin, panelWidth - toggleWidth + margin));
- minTop = Math.min(maxTop, panelHeight + (2.5 * margin));
+ var adapter = mediaAdapters[config.browserMediaAdapterName];
+
+ if (typeof adapter !== 'function') {
+ return Promise.reject(new Error(strings.browserAudioUnavailable || 'The configured browser audio adapter is unavailable.'));
}
- return {
- minLeft: minLeft,
- minTop: minTop,
- maxLeft: maxLeft,
- maxTop: maxTop
- };
- }
+ if (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function') {
+ return Promise.reject(new Error(strings.microphoneUnavailable || 'The microphone is unavailable.'));
+ }
- function clampPosition(left, top) {
- var range = getAvailablePositionRange();
+ browserAudioPromise = connection.invoke('GetCredentials').then(function (credentials) {
+ if (!credentials ||
+ credentials.audioMode !== AUDIO_MODES.Browser ||
+ credentials.browserMediaAdapterName !== config.browserMediaAdapterName) {
+ throw new Error(strings.browserAudioUnavailable || 'The configured browser audio adapter is unavailable.');
+ }
- return {
- left: clamp(left, range.minLeft, range.maxLeft),
- top: clamp(top, range.minTop, range.maxTop)
- };
- }
+ return navigator.mediaDevices.getUserMedia({ audio: true }).then(function (stream) {
+ localAudioStream = stream;
- function createStoredPosition(left, top) {
- var range = getAvailablePositionRange();
- var leftSpan = Math.max(0, range.maxLeft - range.minLeft);
- var topSpan = Math.max(0, range.maxTop - range.minTop);
+ return Promise.resolve(adapter({
+ credentials: credentials,
+ localStream: stream,
+ remoteAudioElement: dom.remoteAudio,
+ setRemoteStream: setRemoteAudioStream,
+ showError: showError
+ }));
+ });
+ }).then(function (session) {
+ browserAudioSession = session || {};
- return {
- left: left,
- top: top,
- leftRatio: leftSpan === 0 ? 0 : (left - range.minLeft) / leftSpan,
- topRatio: topSpan === 0 ? 0 : (top - range.minTop) / topSpan
- };
- }
+ return browserAudioSession;
+ }).catch(function (error) {
+ releaseBrowserAudio();
- function resolveStoredPosition(storedPosition) {
- if (!storedPosition) {
- return null;
- }
+ throw error;
+ }).finally(function () {
+ browserAudioPromise = null;
+ });
- var range = getAvailablePositionRange();
- var left = Number(storedPosition.left);
- var top = Number(storedPosition.top);
- var leftRatio = Number(storedPosition.leftRatio);
- var topRatio = Number(storedPosition.topRatio);
+ return browserAudioPromise;
+ }
- if (Number.isFinite(leftRatio)) {
- left = range.minLeft + Math.max(0, range.maxLeft - range.minLeft) * leftRatio;
+ function notifyBrowserAudio(call) {
+ if (!browserAudioSession || !localAudioStream) {
+ return;
}
- if (Number.isFinite(topRatio)) {
- top = range.minTop + Math.max(0, range.maxTop - range.minTop) * topRatio;
- }
+ var stateName = normalizeState(call && call.state);
+ var microphoneEnabled = stateName === 'Connected' && !call.isMuted;
- if (!Number.isFinite(left) || !Number.isFinite(top)) {
- return null;
- }
+ localAudioStream.getAudioTracks().forEach(function (track) {
+ track.enabled = microphoneEnabled;
+ });
- return clampPosition(left, top);
+ if (typeof browserAudioSession.handleCallState === 'function') {
+ Promise.resolve(browserAudioSession.handleCallState(call || null)).catch(function (error) {
+ showError(error && error.message ? error.message : String(error));
+ });
+ }
}
- function persistPosition() {
- var rect = rootElement.getBoundingClientRect();
+ function invokeWithBrowserAudio(method, payload) {
+ return ensureBrowserAudio().then(function () {
+ return invoke(method, payload);
+ }).catch(function (error) {
+ showError(error && error.message ? error.message : String(error));
- saveLayout({
- position: createStoredPosition(rect.left, rect.top)
+ return null;
});
}
- function applyDefaultPosition() {
- // Place the soft phone beside the AI chat widget, when present, so they do not overlap.
- var chatToggle = document.querySelector('.ai-chat-widget-toggle');
+ function showView(name) {
+ dom.views.forEach(function (view) {
+ show(view, view.getAttribute('data-telephony-view') === name);
+ });
+ }
- if (!chatToggle) {
+ function syncViewHeight() {
+ if (!dom.panel || dom.panel.hidden || !dom.keypadView) {
return;
}
- var chatRect = chatToggle.getBoundingClientRect();
- var size = rootElement.getBoundingClientRect();
- var width = size.width || 56;
- var left = chatRect.left - width - 14;
+ var previousHidden = dom.keypadView.hidden;
+ var previousPosition = dom.keypadView.style.position;
+ var previousVisibility = dom.keypadView.style.visibility;
+ var previousPointerEvents = dom.keypadView.style.pointerEvents;
+ var previousInset = dom.keypadView.style.inset;
- if (left < 8) {
- left = chatRect.right + 14;
+ if (previousHidden) {
+ dom.keypadView.hidden = false;
+ dom.keypadView.style.position = 'absolute';
+ dom.keypadView.style.inset = '0 auto auto 0';
+ dom.keypadView.style.visibility = 'hidden';
+ dom.keypadView.style.pointerEvents = 'none';
+ }
+
+ var height = Math.ceil(dom.keypadView.getBoundingClientRect().height || dom.keypadView.scrollHeight || 0);
+
+ if (previousHidden) {
+ dom.keypadView.hidden = previousHidden;
+ dom.keypadView.style.position = previousPosition;
+ dom.keypadView.style.inset = previousInset;
+ dom.keypadView.style.visibility = previousVisibility;
+ dom.keypadView.style.pointerEvents = previousPointerEvents;
+ }
+
+ if (height > 0) {
+ rootElement.style.setProperty('--telephony-view-height', height + 'px');
+ }
+ }
+
+ function activeTabExists() {
+ return dom.tabs.some(function (tab) {
+ return tab.getAttribute('data-telephony-tab') === activeTab;
+ });
+ }
+
+ function ensureActiveTab() {
+ if (activeTabExists()) {
+ return;
+ }
+
+ activeTab = dom.tabs.length ? dom.tabs[0].getAttribute('data-telephony-tab') : 'keypad';
+ }
+
+ function isTelephonyTab(tab) {
+ return tab === 'keypad' || tab === 'history';
+ }
+
+ function hasExtensionTabs() {
+ return dom.tabs.some(function (tab) {
+ return !isTelephonyTab(tab.getAttribute('data-telephony-tab'));
+ });
+ }
+
+ function statusTextForState(stateName) {
+ var key = stateName.charAt(0).toLowerCase() + stateName.slice(1);
+
+ return strings[key] || stateName;
+ }
+
+ function statusTextForCall(call) {
+ if (metadataBoolean(call, 'isConference')) {
+ return strings.inConference || 'In conference';
+ }
+
+ return statusTextForState(normalizeState(call && call.state));
+ }
+
+ function getPeerNumber(call) {
+ if (!call) {
+ return '';
+ }
+
+ var inbound = call.direction === 1 || call.direction === 'Inbound';
+
+ if (inbound) {
+ return call.from || call.to || '';
+ }
+
+ return call.to || call.from || '';
+ }
+
+ function metadataBoolean(call, key) {
+ if (!call || !call.metadata || !Object.prototype.hasOwnProperty.call(call.metadata, key)) {
+ return false;
+ }
+
+ var value = call.metadata[key];
+
+ return value === true || value === 1 || value === 'true' || value === 'True';
+ }
+
+ function getActiveCalls() {
+ return Object.keys(activeCalls).map(function (callId) {
+ return activeCalls[callId];
+ }).filter(function (call) {
+ return call && isActive(normalizeState(call.state));
+ }).sort(function (left, right) {
+ return Date.parse(right.startedUtc || 0) - Date.parse(left.startedUtc || 0);
+ });
+ }
+
+ function selectCurrentCall(call) {
+ if (!call) {
+ currentCall = null;
+
+ return;
+ }
+
+ activeCalls[call.callId] = call;
+ currentCall = call;
+ }
+
+ function removeActiveCall(callId) {
+ if (!callId) {
+ return;
+ }
+
+ delete activeCalls[callId];
+ delete conferenceSelections[callId];
+
+ if (currentCall && currentCall.callId === callId) {
+ currentCall = getActiveCalls()[0] || null;
+ }
+ }
+
+ function upsertActiveCall(call, select) {
+ if (!call || !call.callId) {
+ return;
+ }
+
+ var stateName = normalizeState(call.state);
+
+ if (!isActive(stateName)) {
+ removeActiveCall(call.callId);
+
+ return;
+ }
+
+ activeCalls[call.callId] = call;
+
+ if (select || !currentCall || currentCall.callId === call.callId) {
+ currentCall = call;
+ }
+ }
+
+ // ---- Layout persistence and dragging ----
+
+ function loadLayout() {
+ try {
+ var layout = JSON.parse(localStorage.getItem(storageKey)) || {};
+
+ if (Object.prototype.hasOwnProperty.call(layout, 'phoneNumber')) {
+ localStorage.removeItem(storageKey);
+
+ return {};
+ }
+
+ return layout;
+ } catch (e) {
+ return {};
+ }
+ }
+
+ function saveLayout(patch) {
+ try {
+ var layout = loadLayout();
+ Object.assign(layout, patch);
+ localStorage.setItem(storageKey, JSON.stringify(layout));
+ } catch (e) {
+ // Ignore storage errors (for example private browsing).
+ }
+ }
+
+ function applyRootPosition(left, top) {
+ rootElement.style.left = left + 'px';
+ rootElement.style.top = top + 'px';
+ rootElement.style.right = 'auto';
+ rootElement.style.bottom = 'auto';
+ }
+
+ function getAvailablePositionRange() {
+ var toggleRect = rootElement.getBoundingClientRect();
+ var toggleWidth = toggleRect.width || 56;
+ var toggleHeight = toggleRect.height || 56;
+ var margin = 8;
+
+ // Keep the toggle on screen so the widget can be dragged to any edge, including the far
+ // right and over other widgets such as the AI chat widget.
+ var maxLeft = Math.max(margin, window.innerWidth - toggleWidth - margin);
+ var maxTop = Math.max(margin, window.innerHeight - toggleHeight - margin);
+ var minLeft = margin;
+ var minTop = margin;
+
+ if (dom.panel && !dom.panel.hidden) {
+ var panelRect = dom.panel.getBoundingClientRect();
+ var panelWidth = panelRect.width || toggleWidth;
+ var panelHeight = panelRect.height || 0;
+
+ // The panel is anchored to the right of the toggle and floats above it, so it extends
+ // to the left and up. Keep the panel within the viewport so its header stays grabbable.
+ minLeft = Math.min(maxLeft, Math.max(margin, panelWidth - toggleWidth + margin));
+ minTop = Math.min(maxTop, panelHeight + (2.5 * margin));
+ }
+
+ return {
+ minLeft: minLeft,
+ minTop: minTop,
+ maxLeft: maxLeft,
+ maxTop: maxTop
+ };
+ }
+
+ function clampPosition(left, top) {
+ var range = getAvailablePositionRange();
+
+ return {
+ left: clamp(left, range.minLeft, range.maxLeft),
+ top: clamp(top, range.minTop, range.maxTop)
+ };
+ }
+
+ function createStoredPosition(left, top) {
+ var range = getAvailablePositionRange();
+ var leftSpan = Math.max(0, range.maxLeft - range.minLeft);
+ var topSpan = Math.max(0, range.maxTop - range.minTop);
+
+ return {
+ left: left,
+ top: top,
+ leftRatio: leftSpan === 0 ? 0 : (left - range.minLeft) / leftSpan,
+ topRatio: topSpan === 0 ? 0 : (top - range.minTop) / topSpan
+ };
+ }
+
+ function resolveStoredPosition(storedPosition) {
+ if (!storedPosition) {
+ return null;
+ }
+
+ var range = getAvailablePositionRange();
+ var left = Number(storedPosition.left);
+ var top = Number(storedPosition.top);
+ var leftRatio = Number(storedPosition.leftRatio);
+ var topRatio = Number(storedPosition.topRatio);
+
+ if (Number.isFinite(leftRatio)) {
+ left = range.minLeft + Math.max(0, range.maxLeft - range.minLeft) * leftRatio;
+ }
+
+ if (Number.isFinite(topRatio)) {
+ top = range.minTop + Math.max(0, range.maxTop - range.minTop) * topRatio;
+ }
+
+ if (!Number.isFinite(left) || !Number.isFinite(top)) {
+ return null;
+ }
+
+ return clampPosition(left, top);
+ }
+
+ function persistPosition() {
+ var rect = rootElement.getBoundingClientRect();
+
+ saveLayout({
+ position: createStoredPosition(rect.left, rect.top)
+ });
+ }
+
+ function applyDefaultPosition() {
+ // Place the soft phone beside the AI chat widget, when present, so they do not overlap.
+ var chatToggle = document.querySelector('.ai-chat-widget-toggle');
+
+ if (!chatToggle) {
+ return;
+ }
+
+ var chatRect = chatToggle.getBoundingClientRect();
+ var size = rootElement.getBoundingClientRect();
+ var width = size.width || 56;
+ var left = chatRect.left - width - 14;
+
+ if (left < 8) {
+ left = chatRect.right + 14;
}
var position = clampPosition(left, chatRect.top);
@@ -293,6 +960,10 @@
function restoreLayout() {
var layout = loadLayout();
+ if (typeof layout.activeTab === 'string' && layout.activeTab.length) {
+ activeTab = layout.activeTab;
+ }
+
if (layout.open && dom.panel) {
dom.panel.hidden = false;
}
@@ -402,262 +1073,968 @@
});
}
- // ---- Rendering ----
+ // ---- Rendering ----
+
+ function updateTabs() {
+ dom.tabs.forEach(function (tab) {
+ var selected = tab.getAttribute('data-telephony-tab') === activeTab;
+ tab.classList.toggle('is-active', selected);
+ tab.setAttribute('aria-selected', selected ? 'true' : 'false');
+ });
+ }
+
+ function persistActiveTab() {
+ saveLayout({ activeTab: activeTab });
+ }
+
+ function setActiveTab(tab) {
+ activeTab = tab;
+ persistActiveTab();
+ render();
+
+ if (tab === 'history') {
+ loadHistory();
+ }
+ }
+
+ function renderActiveCalls() {
+ if (!dom.activeCalls || !dom.activeCallsList) {
+ return;
+ }
+
+ var calls = getActiveCalls();
+ show(dom.activeCalls, calls.length > 1);
+
+ dom.activeCallsList.innerHTML = calls.map(function (call) {
+ var callId = call.callId || '';
+ var selected = !!conferenceSelections[callId];
+ var current = currentCall && currentCall.callId === callId;
+ var number = formatPhoneNumber(getPeerNumber(call)) || callId;
+ var state = statusTextForCall(call);
+
+ return '
' +
+ '' +
+ '
';
+ }).join('');
+
+ Array.prototype.forEach.call(dom.activeCallsList.querySelectorAll('[data-telephony-call-select]'), function (button) {
+ button.addEventListener('click', function () {
+ var callId = button.getAttribute('data-telephony-call-select');
+
+ if (activeCalls[callId]) {
+ selectCurrentCall(activeCalls[callId]);
+ render();
+ }
+ });
+ });
+
+ Array.prototype.forEach.call(dom.activeCallsList.querySelectorAll('[data-telephony-conference-call]'), function (checkbox) {
+ checkbox.addEventListener('change', function () {
+ var callId = checkbox.getAttribute('data-telephony-conference-call');
+
+ if (checkbox.checked) {
+ conferenceSelections[callId] = true;
+ selectCurrentCall(activeCalls[callId]);
+ } else {
+ delete conferenceSelections[callId];
+ }
+
+ render();
+ });
+ });
+ }
+
+ function renderDirectory() {
+ if (!dom.directory || !dom.directoryList) {
+ return;
+ }
+
+ show(dom.directory, transferOpen && has(CAPABILITIES.Directory));
+
+ if (!directoryEntries.length) {
+ dom.directoryList.innerHTML = '