technical
WebChat — Controller & Service Skill Reference
Platform: Both Legacy (.NET Framework) and Core (.NET 6) — Shared Enums/Utils: Utils.Enums.SoproChat / Utils.SoProAPI.WebChatApi
External Dependency: WotNot Chat Platform (chat.sopro.io) — OAuth 2.0 authentication
Data Access: EF Core (Legacy Portal) + Dapper (Core API / Queue / Personalisation)
Database Schema
WebChatClient
| Column | Type | Constraints | FK | Description |
|---|---|---|---|---|
Id | int | PK, Identity | — | Primary key |
ClientId | int | Required | Client.Id | Sopro client |
AccountId | int | Required | — | WotNot account ID |
AccountKey | nvarchar(50) | Nullable | — | WotNot account key |
AccountName | nvarchar(255) | Nullable | — | WotNot display name |
ScriptName | nvarchar(100) | Nullable | — | Widget script identifier (injected as chat.sopro.io/chat-widget/{ScriptName}.js) |
OwnerId | int | Required | AspNetUsers.Id | Account owner |
OwnerEmail | nvarchar(100) | Nullable | — | Owner email |
InitializationDate | datetime2 | Required | — | When WotNot account was created |
FirstActivationDate | datetime2 | Nullable | — | First time activated on website |
ConfigurationId | int | Required | — | WotNot configuration reference for settings updates |
ApiToken | nvarchar(500) | Nullable | — | Cached WotNot API token |
Indexes: Non-clustered on ClientId, AccountId. Queried by: ClientId (most common), AccountId (webhook processing).
WebChatUser
| Column | Type | Constraints | FK | Description |
|---|---|---|---|---|
Id | int | PK, Identity | — | Primary key |
ClientId | int | Required | — | Denormalized for fast lookup |
WebChatClientId | int | Required | WebChatClient.Id | Parent WebChat client |
UserId | int | Required | AspNetUsers.Id | Sopro portal user |
UserRole | nvarchar(50) | Nullable | — | "admin" or "agent" |
Email | nvarchar(100) | Nullable | — | User email |
AccountUserId | int | Required | — | WotNot user ID (foreign platform) |
DateAddedToAccount | datetime2 | Required | — | When added to WotNot |
IsDeleted | bit | Required, Default: 0 | — | Soft delete |
IsOffline | bit | Required, Default: 0 | — | Agent offline status |
Query pattern: GetWebChatUserForMultipleAccounts(userId) — finds all WebChat accounts a user belongs to.
SoproChatActivity (WotNot webhook → CRM mapping)
| Column | Type | Constraints | Description |
|---|---|---|---|
Id | int | PK, Identity | Primary key |
DateCreated | datetime2 | Required | When webhook was received |
ConversationId | nvarchar(128) | Required | WotNot conversation UUID |
ProspectId | int | Nullable | Matched Sopro prospect |
CompanyDomain | nvarchar(max) | Nullable | Extracted from visitor |
CompanyId | int | Nullable | Matched Sopro company |
SalesAgentId | int | Nullable | Assigned agent (0 = unassigned) |
SalesAgentName | nvarchar(128) | Nullable | Agent display name |
PhoneNumber | nvarchar(512) | Nullable | From chat variables |
Email | nvarchar(512) | Nullable | From chat variables — primary prospect lookup key |
Location | nvarchar(512) | Nullable | Geo-location from IP |
VisitorName | nvarchar(512) | Nullable | Prospect display name |
ConversationStarted | bit | Required | Real conversation (not just form) |
ConversationClosed | bit | Required | Conversation ended |
AccountId | int | Required | WotNot account — maps to WebChatClient.AccountId |
CampaignId | int | Nullable | Assigned lead campaign |
IsBusinessEmailProvided | bit | Required | Non-public email domain |
ConversationOutOfWorkingHours | bit | Required | Outside business hours |
ResponseTime | time | Nullable | Agent response time |
FormOnlyResponse | bit | Required | No live chat, only form data |
Key queries: GetByConversationIdAsync(conversationId), GetUnansweredConversationsForAccountId(accountId, fromTime). Queue processing joins: WebChatClient (via AccountId), Company (via domain + clientId), Prospect (via email).
SoproChatActivityLog (Webhook event journal)
| Column | Type | Description |
|---|---|---|
Id | int PK | Primary key |
ConversationId | nvarchar(128) | WotNot conversation UUID |
DateCreated | datetime2 | Event timestamp |
EventType | int (enum) | WebChatEventTypeEnum |
MessageType | int? (enum) | WebChatMessageTypeEnum (message events only) |
ChatAssignedToUserId | int? | Assignee change target |
ChatAssignedToBotId | int? | Bot assignment target |
IsLeadEvent | bit | Lead capture event |
EmailProvided | nvarchar(512) | Email captured in variables |
PhoneNumberProvided | nvarchar(512) | Phone captured in variables |
Query: GetByConversationIdAsync(conversationId) — ordered by DateCreated, used by TaskSoproChat to track assignee changes.
SoproChatMessage
| Column | Type | Description |
|---|---|---|
Id | int PK | Primary key |
SoproChatActivityId | int FK | Parent conversation |
ConversationId | nvarchar | WotNot conversation UUID |
MessageId | nvarchar | WotNot message UUID |
SenderType | nvarchar | "visitor" | "agent" | "bot" |
MessageType | int (enum) | WebChatMessageTypeEnum |
Content | nvarchar(max) | Message body (text/images/JSON) |
DateTimeSent | datetime2 | When sent |
Controllers — Full Action Reference
WebChatController /WebChat — Portal Management
File:Portal.Web/Controllers/WebChatController.cs— DI: 5 (IWebsiteWidgetService, IWebChatClientService, IWebChatLogService, ISoproChatActivityService, IWebChatApiService forwarded to base) — Auth:[Authorize]+[MobileDeviceFilter]
| Action | HTTP | Route | Permission | Description |
|---|---|---|---|---|
Index(bool getStarted = false) | GET | /WebChat | — | Main dashboard. On getStarted=true: calls _webChatApiService.CreateAccountAsync() to initialize WotNot account. Stores access_token and account_id in session. Sets WebChatInitialized=true. |
GetStarted(string error) | GET | /WebChat/GetStarted | — | First-time setup page. Shows initialization status, error messages. |
Settings() | GET | /WebChat/Settings | — | WebChat configuration: website URL, script name, personalisation toggle. |
Users() | GET | /WebChat/Users | — | Agent management: list, add, remove, role assignment, online/offline toggle. |
Reports() | GET | /WebChat/Reports | — | Analytics dashboard: conversations, response times, leads, agent performance. |
Logs() | GET | /WebChat/Logs | — | Audit trail of all WebChat configuration changes. |
Session Keys Used: WidgetSelectedClient, WebChatToken, WebChatAccountId. Error Handling: If UseWebChat && !WebChatInitialized and getStarted is false → redirects to GetStarted. If CreateAccountAsync returns null → redirects with error.
LiveChatController /LiveChat — Agent Chat Interface
File:Portal.Web/Controllers/LiveChatController.cs— DI: 1 (IWebChatClientService) — Auth:[Authorize]+[MobileDeviceFilter]
| Action | HTTP | Route | Permission | Description |
|---|---|---|---|---|
Index(string botId, string conversationId, string accountId) | GET | /LiveChat | — | Agent chat dashboard. Validates accountId against current client's WebChatClient. If mismatch, redirects to first active client's account. Sets ViewBag.IsOffline. Loads WotNot chat interface with agent credentials. |
Validation Flow: accountId → GetWebChatClientByAccountIdAsync → check webChatClient.ClientId == SelectedClient.Id. If not → find first active campaign, switch client, redirect.
WebSitePluginController /WebSitePlugin — Widget Configuration
File:Portal.Web/Controllers/WebSitePluginController.cs— DI: 4 (IWebsiteWidgetService, IProspectIntentTrackingService, IGeneralSettingsService, IWebChatClientService) — Auth:[Authorize]
| Action | HTTP | Description |
|---|---|---|
Index() | GET | Website plugin settings: widget website URL, WebChat integration status, intent tracking configuration. Displays WidgetSelectedClient.WidgetWebSite and WebChat initialization state. |
SoproChatController /api/SoproChat — Webhook Handler (Core)
File:SoproCoreAPI/Controllers/SoproChatController.cs— DI: 17 — Auth: Webhook Custom Authorization (commented://[WebChatCustomAutorization])
| Action | HTTP | Description |
|---|---|---|
Webhook([FromBody] object payload) | POST | Primary webhook endpoint. Parses WotNot event JSON. Dispatches by EventType: creates/updates SoproChatActivity, inserts SoproChatActivityLog. For lead events: validates email domain (non-public), creates/excludes prospect. Enqueues SoProQueueMessage for TaskSoproChat processing. Thread-safe via _lockObject. |
GetConversations() | GET | Returns paginated conversations for the authenticated account. |
GetMessages(string conversationId) | GET | Returns all messages for a conversation, excluding bot messages. |
ResolveConversation(int id, string resolution) | POST | Auto/manual conversation resolution. Updates ConversationRosolvedDate / ConversationRosolvedByUserDate. |
AssignConversation(int id, int userId) | POST | Assigns conversation to agent. Updates SalesAgentId. |
Webhook Payload Processing:
- Parse JSON → extract
event,conversation_id,account_id - Get or create
SoproChatActivitybyConversationId - Insert
SoproChatActivityLogwith event type, message type, assignee, variables - If lead event: validate email domain → check
PublicEmailDomains→ setIsBusinessEmailProvided - If
ConversationStarted: enqueueSoProQueueTypeEnum.SoproChatmessage for async processing - SignalR hub notification to connected clients
SoproChatOldController /api/SoproChatOld — Legacy Webhook (Core)
File: SoproCoreAPI/Controllers/SoproChatOldController.cs — DI: 17 — Auth: Webhook
Legacy webhook handler maintained during platform migration. Same dependency set and processing logic as SoproChatController. Routes requests to the older webhook format expected by WotNot's legacy integration.
ScriptController /script — Widget Injection (Personalisation)
File: sopro-personalisation/Controllers/ScriptController.cs — DI: 15 — Auth: Public (rate-limited)
| Action | HTTP | Route | Description |
|---|---|---|---|
Hq(string key) | GET | /script/hq.js | Standard widget script. Delegates to HqInternal(key, false). |
HqSpa(string key) | GET | /script/hqspa.js | SPA variant. Delegates to HqInternal(key, true). Uses CoreSPA view instead of Core. |
IpLookup([FromBody] IPLookupModel) | POST | /script/iplookup | IP-to-company resolution. Calls AnonymousIP API. Returns prospect JSON with company, domain, logo, screenshot. If WebChat active + domain known → enriches with company DB data. |
Personalize(string token) | GET | /script/personalize | Personalisation script view. |
Prospect(string prospectToken) | GET | /script/prospect | Prospect-specific script view. |
Admin(string token, string secret) | GET | /script/admin | Admin debug script (requires outbaseadmin=1 query param). |
HqInternal Processing Pipeline:
- Validate
PropertySettingsby key → 404 if null (or WrongId if validation mode) - Check
Activeflag → error if inactive client - Domain validation (if
_validateobwquery param) - Fetch
ClientData→ checkWebChatActive - If WebChatActive: fetch
WebChatClientData.ScriptNameviaIWebChatClientService - Plugin logging (if enabled)
- Security checks: Bot detection → Browser check → Rate limiting → IP blacklist
- Exclusion/Inclusion rules check
- Prospect lookup by
_obid(GUID) or_obidt(Base36-encoded int) - If
IsFullWebChatPersonalizationActive: enriched prospect data - Script assembly: [Core|CoreSPA] + [WebChatScript] + [Personalize] + [Admin] + [Final]
- Render all views via
ViewToStringRendererService
Rate Limiting: 50 requests per IP per 1-minute window. Exceeded → blocked request logged.
Queue Processing — TaskSoproChat
File:SoProQueueConsumer/Partials/TaskSoproChat.cs— Platform: Core — Input:ConsoleTaskParametarswithchatActivityId
Processing Algorithm
- Load Activity:
_dapperSoproChatActivityService.FindByIdAsync(chatActivityId) - Load Context: Messages → ActivityLogs → WebChatClient (by AccountId) → GeneralSettings
- Prospect Resolution:
- Primary:
_dapperProspectService.GetProspectsByEmailAsync(chatActivity.Email)— exact match only if count==1 - Secondary: Look up by
_obidfrom webhook variables (parsed from JSON)
- Primary:
- Company Matching:
_dapperCompanyService.GetCompanyByDomainAndClientId(chatActivity.CompanyDomain, webChatClient.ClientId) - Campaign Assignment:
webChatClient.Client.WebchatLeadCampaignId→ assign toSoproChatActivity.CampaignId - Prospect Creation (if new): build from chat data (name, email, phone, company)
- CRM Sync: Trigger
_crmSyncTriggerServicefor prospect + company - Exclusion Check: Validate against exclusion rules (
_dapperExclusionService) - Assignee Tracking: Sort
AssigneeChangelogs by DateCreated → track handoffs - Update Activity: Save resolved
SoproChatActivitywith all matched data
Error Handling
| Condition | Result |
|---|---|
| Activity not found | Success=false, Log: "SoproChatActivity not found for Id={id}" |
| WebChatClient not found | Success=false, Log: "WebChatClient not found for AccountId={id}" |
| Multiple prospects by email | Prospect NOT auto-assigned (ambiguous match) |
| Company not found by domain | Prospect created without company link |
| Any exception | Logged to ApplicationsErrorLog, Success=false |
WotNot API Client — Full Reference
File: Utils/SoProAPI/WebChatApi.cs — Shared across both platforms
| Method | Signature | Auth | Purpose |
|---|---|---|---|
Token | Task<string> Token(WebChatClientCredentialsModel) | client_id + client_secret | GET OAuth token from /oauth/token |
TokenSync | string TokenSync(WebChatClientCredentialsModel) | client_id + client_secret | Synchronous version (WebClient) |
Post<TIn,T> | Task<T> Post(credentials, route, TIn) | Bearer token | Generic POST — creates accounts, users, configs |
PostSyncNoToken<TIn,T> | T PostSyncNoToken(route, TIn) | None (admin login) | Admin API POST — used for feature config |
GetSyncWithCookie<T> | T GetSyncWithCookie(url, cookie) | Cookie (session) | Admin API GET — fetches account features |
Put<TIn,T> | Task<T> Put(credentials, route, TIn) | Bearer token | Generic PUT — updates settings |
Get<T> | Task<T> Get(credentials, route) | Bearer token | Generic GET — retrieves data |
Delete<T> | Task<T> Delete(credentials, route) | Bearer token | Generic DELETE — removes users |
Admin API Flow (Feature Configuration)
PostSyncNoToken<AdminLoginModel, UserLoginResponseModel>("/login", model)→ getsrefresh_token+ingrescookie+sessionGetSyncWithCookie<WebchatAdminPropertiesModel>(apiUrl + "/accounts/" + accountId + "/features", cookie)→ gets feature containers- Iterate
Results[].Features[]→ update feature flags →Putback
DI Registration Reference
| Interface | Implementation | Lifetime | Platform | File |
|---|---|---|---|---|
IWebChatClientService | WebChatClientService | Scoped | Legacy Portal | SimpleInjectorInitializer.cs |
IWebChatClientService | WebChatClientService | Scoped | Core Portal | Program.cs |
IDapperWebChatClientService | DapperWebChatClientService | Scoped | Core API | SoproCoreAPI/Program.cs |
IDapperWebChatClientService | DapperWebChatClientService | Scoped | Queue Consumer | InitServices.cs |
ISoproChatActivityService | SoproChatActivityService | Scoped | Both Portals | SimpleInjector / Program.cs |
IWebChatClientService | WebChatClientService (personalisation) | Scoped | Personalisation | Startup.cs |
IWebChatClientData | WebChatClientData | Scoped | Personalisation | Startup.cs |
Enums — Complete Reference
WebChatEventTypeEnum
| Value | Name | Friendly Name (WotNot JSON) | Usage |
|---|---|---|---|
| 1 | ConversatoinCreate | "conversation_create" | New chat started. Triggers SoproChatActivity insert + queue. |
| 2 | Message | "message" | New message. Logged in SoproChatActivityLog + SoproChatMessage. |
| 3 | Status | "status" | Conversation status change (open/closed/resolved). |
| 4 | AssigneeChange | "assignee_change" | Agent assignment changed. Tracked for handoff analysis. |
| 5 | Variables | "variables" | Session variables updated (email, phone, name captured). |
| 6 | ConversationLabels | "conversation_labels" | Labels applied to conversation. |
| 7 | Note | "note" | Internal agent note added. |
WebChatMessageTypeEnum
| Value | Name | Friendly Name | Value | Name | Friendly Name |
|---|---|---|---|---|---|
| 1 | Text | text | 12 | Document | document |
| 2 | Image | image | 13 | Carousel | carousel |
| 3 | Form | form | 14 | JavascriptResponse | javascript.response |
| 4 | FileUpload | file_upload | 15 | SliderResponse | slider.response |
| 5 | Calendar | calendar | 16 | Slider | slider |
| 6 | Audio | audio | 17 | AppointmentBookingResponse | appointment_booking.response |
| 7 | CalendlyResponse | calendly.response | 18 | ButtonResponse | button.response |
| 8 | Video | video | 19 | FormResponse | form.response |
| 9 | List | list | 20 | FileUploadResponse | file_upload.response |
| 10 | Javascript | javascript | 21 | AppointmentBooking | appointment_booking |
| 11 | Button | button | — | — | — |
SoproChatActivityTypeEnum
| Value | Name | Friendly Name | Description |
|---|---|---|---|
| 1 | ChatSession | "Webchat session" | Live conversation between prospect and agent |
| 2 | ChatLead | "Webchat lead" | Lead captured via chat (form, email provided) |
Configuration Keys
| Key Path | Type | Platform | Description |
|---|---|---|---|
Client.WebChatActive | bool | DB | Master switch. Checked by ScriptController.HqInternal. |
Client.UseWebChat | bool | DB | Legacy flag. Checked by WebChatController for initialization. |
Client.WebchatLeadCampaignId | int? | DB | Campaign for chat leads. Used by TaskSoproChat. |
Client.WebChatInitialized | bool | Session | Runtime flag. Set after CreateAccountAsync succeeds. |
PropertySettings.IsFullWebChatPersonalizationActive | bool | DB | Enables enriched prospect data in chat widget. |
WebChatClient.ScriptName | string | DB | WotNot script identifier. Injected as chat widget src. |
GeneralSettings.WebChatClientCredentials | object | DB/Settings | WotNot API credentials (ApiUrl, ClientId, ClientSecret). |
Key SQL Queries (Dapper)
Personalisation — WebChatClientData
SELECT ScriptName FROM WebChatClient WHERE ClientId = @clientId
Personalisation — ClientData (WebChatActive check)
SELECT Id, ClientName, WebChatActive FROM client WHERE Id = @clientId
Core — TaskSoproChat (prospect by email)
SELECT * FROM Prospect WHERE Email = @Email AND ClientId = @ClientId
Core — TaskSoproChat (company by domain + client)
SELECT * FROM Company WHERE EmailDomain = @Domain AND ClientId = @ClientId
Core — Unanswered Conversations
SELECT COUNT(*) FROM SoproChatActivity
WHERE AccountId = @accountId AND DateCreated > @fromTime
AND ConversationStarted = 1 AND SalesAgentId = 0
Reporting — Webchat By Client By Day (Grafana)
SELECT wcc.ClientId, CAST(sca.DateCreated AS DATE) AS [Date],
COUNT(DISTINCT sca.ConversationId) AS Conversations,
COUNT(DISTINCT CASE WHEN sca.ConversationStarted = 1 THEN sca.ConversationId END) AS AnsweredChats
FROM SoproChatActivity sca
JOIN WebChatClient wcc ON wcc.AccountId = sca.AccountId
GROUP BY wcc.ClientId, CAST(sca.DateCreated AS DATE)
Error Handling Patterns
| Layer | Pattern | Example |
|---|---|---|
| Personalisation | Return JavaScript error comment | return this.JavascriptErrorContent("Invalid Outbase Key") |
| Portal | Redirect with error message | return RedirectToAction("GetStarted", new { error = "..." }) |
| Core API | Log to ApplicationsErrorLog + return HTTP error | _appErrorLogService.InsertAsync(error) |
| Queue Consumer | ConsoleTaskResponce with Success=false + Log | ret.Success = false; ret.Log = "..." |
| WotNot API | Catch WebException, return default/null | catch (WebException) { return string.Empty; } |
Views & Client-Side
| View | Platform | Purpose |
|---|---|---|
WebChatScript.cshtml | Personalisation | Injects <script src="chat.sopro.io/chat-widget/{ScriptName}.js"> with data-session-payload JSON containing prospect name, email, company, outreach activity, portal URL. |
Core.cshtml | Personalisation | Main widget JS. Passes WebChatScriptName, IsWebchatActive to /iplookup. Calls reloadDataSession() when prospect is known and WebChat is active. |
CoreSPA.cshtml | Personalisation | SPA variant. Same WebChat logic, adapted for single-page apps. |
WebchatMobile.cshtml | Both Portals | Mobile redirect page. Links to iOS app: apps.apple.com/mk/app/sopro-webchat/id6504247831. |
AI Conversation Resolution
Config: WebchatConversationResolverAI — Used by: TaskSoproChat + SoproChatController
Every closed WebChat conversation is automatically resolved by AI into one of four categories. This determines how the conversation is handled downstream (lead creation, support routing, opt-out exclusion, or discard).
Resolution Categories
| Resolution | Enum Value | Trigger | CRM Action |
|---|---|---|---|
| Lead | WebChatConversationResolvedEnum.Lead | Prospect provided business email + phone, expressed sales interest | Prospect created/updated in CRM, campaign assigned, CRM sync triggered |
| Support | WebChatConversationResolvedEnum.Support | Prospect asked a support question, no sales intent detected | Support notification email sent, no lead created |
| Removal | WebChatConversationResolvedEnum.Removal | Prospect explicitly asked to be removed from contact | Auto-exclusion created for the prospect email/domain |
| Invalid | WebChatConversationResolvedEnum.Invalid | No usable contact details, spam, or test conversation | Logged only, no CRM action |
AI Configuration
| Config Key | Purpose |
|---|---|
WebchatConversationResolverAI:ApiUrl | AI resolver endpoint URL |
WebchatConversationResolverAI:BearerToken | Authentication bearer token |
WebchatConversationResolverAI:Model | AI model identifier (e.g. GPT-4) |
WebchatConversationResolverAI:ResponseFormatType | Expected response format (JSON schema) |
WebchatConversationResolverAI:MessageContent | System prompt / instructions for the AI |
Resolution Flow
flowchart TD CHAT["Chat conversation closed"] --> HAS{"Has email or phone?"} HAS -->|"No"| INV["Resolution: Invalid"] HAS -->|"Yes"| AI["Send conversation transcriptto AI resolver API"] AI --> RESULT{"AI response"} RESULT -->|"lead"| LEAD["Resolution: Lead
Create prospect + CRM sync"] RESULT -->|"support"| SUPP["Resolution: Support
Send support notification"] RESULT -->|"removal"| REM["Resolution: Removal
Auto-exclude prospect"] RESULT -->|"invalid"| INV LEAD --> NOTIFY["Send lead notification email"] LEAD --> EXCLUDE["Apply auto-exclusion rules
(same person 360d, domain 180d)"]
Lead Extraction (Pre-Resolution)
Before AI resolution, the system extracts structured lead data from the conversation using a separate AI service (PhoneNumberResponderAI):
- Concatenate all visitor messages into a single text blob
- POST to
PhoneNumberResponderAI:ApiUrlwith authorization header - Parse response for:
Emails[](withIsBusinessEmailflag),Numbers[](phone numbers), names - Filter out emails matching the client's own domain (prevent self-leads)
- Set
IsBusinessEmailProvidedflag — required for Lead resolution
SignalR Real-Time Notifications
Hub: SoproCoreAPI/Hubs/NotificationsHub.cs — Used by: SoproChatController webhook handler
When WotNot sends a webhook event, the controller broadcasts real-time updates to connected agents via SignalR:
| Event | SignalR EventType | Recipient | Purpose |
|---|---|---|---|
| AssigneeChange (auto-assign) | "start" | Specific agent email | New conversation assigned — agent sees popup in Live Chat |
| AssigneeChange (unassign) | "stop" | All agents in account | Conversation unassigned — remove from agent's active list |
| AssigneeChange (no email) | "start" | All agents in account | Broadcast — all agents see new unassigned conversation |
| First visitor message | "start" | All agents in account | New conversation created — appears in Live Chat sidebar |
SignalR Message Model
{
"EventType": "start" | "stop",
"EventRecipient": "all" | "agent@company.com",
"ConversationId": "conv_7a3f9b2c",
"BotId": 12345,
"AccountId": 67890
}
Group-Based Broadcasting
SignalR groups are keyed by accountId — all agents viewing the same WotNot account receive broadcasts for that account only. This isolates notifications per client.
Email Notification Templates
Template Engine: SendGrid — Used by: TaskSoproChat (via DapperSoproChatActivityService.SendNotificationEmail)
Notification Types
| Template | SendGrid Enum | Subject | Trigger |
|---|---|---|---|
| Out of Working Hours Lead | OutOfWorkingHoursLeadTemplateId | Via SendGrid template | AI resolves as Lead AND conversation was outside business hours AND no agent assigned (SalesAgentId==0) |
Template Variables
All notification emails receive these variables from BuildLeadNotificationVariables():
| Variable | Source | Example Value |
|---|---|---|
visitor_id | chatActivity.VisitorName | "John Smith" or "Visitor" |
email | chatActivity.Email | "john@acmecorp.com" or "Undefined" |
phone | chatActivity.PhoneNumber | "+44 7911 123456" or "Undefined" |
prospect_email | Matched prospect email | "john.smith@acmecorp.com" or "Undefined" |
company_domain | chatActivity.CompanyDomain | "acmecorp.com" or "Undefined" |
campaign_name | Assigned campaign | "Acme Outreach Q3" or "Undefined" |
is_prospect_identified | bool | true/false |
is_company_identified | bool | true/false |
is_campaign_identified | bool | true/false |
is_email_lead | bool | true/false |
is_phone_lead | bool | true/false |
bot_id | WotNot bot ID | 12345 |
conversation_key | conversationId | "conv_7a3f9b2c" |
account_id | chatActivity.AccountId | 67890 |
subject | Dynamic | "Sopro WebChat: Lead" |
is_support_form_response | bool | true if FormOnlyResponse + Support |
is_lead | bool | true/false |
is_support | bool | true/false |
is_removal | bool | true/false |
Notification Gating Conditions
Notifications are only sent when ALL of these conditions are met:
- AI resolution is Lead, Support, or Removal (not Invalid)
- If Lead: a business email was provided (personal emails like Gmail skip notification)
- No agent is assigned (
SalesAgentId == 0) — if an agent handled it live, no email needed
This prevents notification spam — only unassigned, qualified conversations trigger emails.
Message Persistence Flow
How chat messages are saved from WotNot webhook → database. Three persistence paths depending on event type.
Architecture Overview
flowchart TD WN["WotNot Platform"] -->|"HTTP POST webhook"| SC["SoproChatController.Webhook()"] SC -->|"Message events"| MSG["SoproChatMessage table"] SC -->|"AssigneeChange events"| LOG["SoproChatActivityLog table"] SC -->|"Status events"| LOG SC -->|"All events"| ACT["SoproChatActivity table (create/update)"] MSG --> DB["SQL Server (Sopro DB)"] LOG --> DB ACT --> DBPath 1: Message Events → SoproChatMessage
Trigger: WotNot webhook with event == "message".
| Step | Code | Description |
|---|---|---|
| 1. Parse | messageTypeStr = wchEvent?.Event?.Payload?.Message?.Type | Extract message type (text, image, form, etc.) |
| 2. Format | messageText = wchEvent?.Event?.Payload?.Message?.Text | For FormResponse: iterates Payload.Message.Payload.Fields[], builds "Label - Value, " concatenated string. For all other types: takes Message.Text directly. |
| 3. Lookup Activity | GetSoproChatActivityByConversationIdAsync(conversationId) | Find existing SoproChatActivity row for this conversation |
| 4a. Existing conversation | new DapperSoproChatMessage { SoproChatActivityId, DateTimeSent, SenderType, ParticipantName, MessageType, MessageText } → CreateAsync() | Append message to existing conversation. SenderType = "visitor" | "user" | "bot". |
| 4b. First message | Create SoproChatActivity + then create SoproChatMessage + SignalR broadcast | Only for visitor messages with response types. Extracts variables (visitor_name, visitor_company_domain, country_name, city_name). Creates activity row with ConversationStarted=true. |
Path 2: AssigneeChange Events → SoproChatActivityLog
Trigger: WotNot webhook with event == "assignee_change".
| Step | Code | Description |
|---|---|---|
| 1. Create Log | new DapperSoproChatActivityLog { ConversationId, DateCreated=UtcNow, EventType=AssigneeChange } | Create activity log entry |
| 2. Resolve Target | wchEvent?.Event?.Payload?.To?.Type | If "bot" → set ChatAssignedToBotId from Payload.To.Id. Otherwise → set SenderEmail from Payload.To.Email. |
| 3. Persist | _dapperSoproChatActivityLogService.CreateAsync(activityLog) | INSERT into SoproChatActivityLog table |
Path 3: Status Events → SoproChatActivityLog + Queue
Trigger: WotNot webhook with event == "status" && status == "Close".
| Step | Code | Description |
|---|---|---|
| 1. Log Event | new DapperSoproChatActivityLog { ConversationId, EventType=Status } → CreateAsync() | INSERT close event into ActivityLog |
| 2. Extract Variables | wchEvent?.Event?.Payload?.Variables | Extract visitor_prospect_obid, visitor_company_domain, visitor_prospect_email, country_name, city_name, visitor_name |
| 3. Update Activity | Update SoproChatActivity row: ConversationClosed=true, ConversationCloseTime, extracted variables | Finalize the conversation record |
| 4. Enqueue | Create SoProQueueMessage with QueueType=WebChatProcessing | Queue the conversation for async TaskSoproChat processing (prospect matching, CRM sync, campaign assignment) |
Message Data Flow Diagram
flowchart TD WN["WotNot WebhookPOST /api/SoproChat
event, conversation_id, payload"] WN --> SC["SoproChatController.Webhook()"] SC --> P1{"Parse JSON"} P1 --> P2{"event == message?"} P2 -->|"Yes"| FMT["Format message text
FormResponse → concat fields"] FMT --> LKP["Lookup existing
SoproChatActivity by conv ID"] LKP --> EX{"Exists?"} EX -->|"Yes"| INS1["INSERT SoproChatMessage"] EX -->|"No"| CRT["CREATE SoproChatActivity
+ INSERT SoproChatMessage
+ SignalR broadcast"] P2 -->|"assignee_change"| INS2["INSERT SoproChatActivityLog"] P2 -->|"status == Close"| INS3["INSERT SoproChatActivityLog
+ UPDATE SoproChatActivity (closed)
+ ENQUEUE SoProQueueMessage"] INS1 --> DB["SQL Server (Sopro DB)"] CRT --> DB INS2 --> DB INS3 --> DB DB --> TBL1["SoproChatActivity
Id, ConversationId, VisitorName,
Email, CompanyDomain, AccountId"] DB --> TBL2["SoproChatMessage
Id, SoproChatActivityId, MessageText,
SenderType, DateTimeSent"] DB --> TBL3["SoproChatActivityLog
Id, ConversationId, EventType,
ChatAssignedToUserId"]
Thread Safety
The webhook handler uses _lockObject (a private object field) with lock(_lockObject) to ensure only one webhook event per conversation is processed at a time. This prevents duplicate SoproChatActivity inserts and race conditions on message ordering.
Dapper Repository Pattern
| Service | Base Class | Custom Methods |
|---|---|---|
DapperSoproChatMessageService | DapperRepositoryBaseRepository<DapperSoproChatMessage> | GetBySoproChatActivityIdAsync(int) — SELECT * ORDER BY DateTimeSent |
DapperSoproChatActivityLogService | DapperRepositoryBaseRepository<DapperSoproChatActivityLog> | GetByConversationIdAsync(string) — SELECT * ORDER BY DateCreated |
DapperSoproChatActivityService | DapperRepositoryBaseRepository<DapperSoproChatActivity> | GetSoproChatActivityByConversationIdAsync(string), GetUnansweredConversationsForAccountId(int, DateTime) |
Base Repository provides: FindByIdAsync, CreateAsync, UpdateAsync, DeleteAsync, GetAllAsync. Uses Microsoft.Data.SqlClient with parameterized Dapper queries.
Console Applications
SoProQueueConsumer is the primary console application that processes WebChat conversations. It runs as a Windows service-managed console, auto-restarted every hour by the SoProQueueConsoleManager.
SoProQueueConsumer
| Property | Value |
|---|---|
| Project | SoProQueue/Consoles/SoProQueueConsumer/SoProQueueConsumer.csproj |
| Entry Point | Program.Main(string[] args) — static async Task<int> |
| Platform | .NET 6 Console Application |
| Deployment | Windows Service via SoProQueueConsoleManager |
| Lifecycle | Runs for 1 hour max, then Process.GetCurrentProcess().Kill(). ConsoleManager spawns a new instance. |
Initialization Flow
flowchart TD MAIN["Main(args)"] --> TSI["TheStupidInitalisation()CPU affinity, process priority"] TSI --> IC["InitConsole(args)
Parse console type, wire up DI"] IC --> BUILD["Build IHost with 60+ services"] BUILD --> SVC1["IDapperSoproChatActivityService
(Transient)"] BUILD --> SVC2["IDapperSoproChatActivityLogService
(Transient)"] BUILD --> SVC3["IDapperSoproChatMessageService
(Transient)"] BUILD --> SVC4["IDapperWebChatClientService
(Transient)"] SVC1 --> RSV["Resolve static service fields"] SVC2 --> RSV SVC3 --> RSV SVC4 --> RSV RSV --> PC["ProcessConsole()
Infinite loop"] PC --> GET["Get Azure Queue message"] GET --> LK["Lookup SoProQueueMessages by ID"] LK --> SW{"switch(enumSelected)"} SW -->|"WebChatProcessing"| TSC["TaskSoproChat(params)
WebChat conversation processing"] TSC --> DEL["Delete message, log result, loop"] DEL --> PC
ConsoleTypeEnum.WebChatProcessing Dispatch
| Field | Value |
|---|---|
| Queue Name | Derived from ConsumerHelper.GetQueueForConsole(WebChatProcessing) |
| Message Format | SoProQueueMessages.Message = chatActivityId (int as string) |
| Task Method | TaskSoproChat(ConsoleTaskParametars) → ConsoleTaskResponce |
| Entry in switch | case ConsoleTypeEnum.WebChatProcessing: retTask = await TaskSoproChat(...) |
SoProQueueConsoleManager
| Property | Value |
|---|---|
| Project | SoProQueue/Consoles/SoProQueueConsoleManager/ |
| Purpose | Manages console lifecycle — spawns, monitors, and restarts SoProQueueConsumer instances per queue type. Ensures exactly N instances of WebChatProcessing console are always running. |
| Key Behavior | Spawns new SoProQueueConsumer.exe processes with command-line args specifying ConsoleTypeEnum. Monitors health. Kills stale processes. Re-spawns when a console self-terminates after 1 hour. |
Complete Console Architecture
flowchart TD MGR["SoProQueueConsoleManager(Windows Service)"] MGR --> SP["Spawns & monitors
console processes"] SP --> C1["EmailFinder Console"] SP --> C2["ListBuild Console"] SP --> C3["WebChatProcessing Console"] C1 --> Q1["Azure Queue (EmailFinder)"] C2 --> Q2["Azure Queue (ListBuild)"] C3 --> Q3["Azure Queue (WebChatProcessing)"] Q1 --> PQ1["ProcessQueue()"] Q2 --> PQ2["ProcessQueue()"] Q3 --> PQ3["ProcessQueue()"] PQ1 --> T1["TaskEmailFinder()"] PQ2 --> T2["TaskListBuild()"] PQ3 --> T3["TaskSoproChat()"]
Azure Queue Message Lifecycle
- Enqueue:
SoproChatController.Webhook()createsSoProQueueMessagesrow + pushes message ID to Azure Storage Queue - Dequeue:
SoProQueueConsumer.ProcessConsole()polls Azure Queue via_soProQueueService.GetNext(queue) - Lookup: Message body =
soProQueueMessagesId(int). Fetch fullSoProQueueMessagesrow from DB. - Dispatch:
switch(enumSelected)→TaskSoproChat(new ConsoleTaskParametars { Message = soProQueueMessage.Message }) - Process: TaskSoproChat resolves prospect, company, campaign, CRM sync. Returns
ConsoleTaskResponce { Success, Completed, Log } - Log:
SoProQueueLogrow inserted with success/failure.SoProQueueMessagesrow updated with end time, computer ID. - Delete: Azure queue message deleted. If TaskSoproChat fails → message left in queue for retry.