Skip to main content

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

ColumnTypeConstraintsFKDescription
IdintPK, IdentityPrimary key
ClientIdintRequiredClient.IdSopro client
AccountIdintRequiredWotNot account ID
AccountKeynvarchar(50)NullableWotNot account key
AccountNamenvarchar(255)NullableWotNot display name
ScriptNamenvarchar(100)NullableWidget script identifier (injected as chat.sopro.io/chat-widget/{ScriptName}.js)
OwnerIdintRequiredAspNetUsers.IdAccount owner
OwnerEmailnvarchar(100)NullableOwner email
InitializationDatedatetime2RequiredWhen WotNot account was created
FirstActivationDatedatetime2NullableFirst time activated on website
ConfigurationIdintRequiredWotNot configuration reference for settings updates
ApiTokennvarchar(500)NullableCached WotNot API token

Indexes: Non-clustered on ClientId, AccountId. Queried by: ClientId (most common), AccountId (webhook processing).

WebChatUser

ColumnTypeConstraintsFKDescription
IdintPK, IdentityPrimary key
ClientIdintRequiredDenormalized for fast lookup
WebChatClientIdintRequiredWebChatClient.IdParent WebChat client
UserIdintRequiredAspNetUsers.IdSopro portal user
UserRolenvarchar(50)Nullable"admin" or "agent"
Emailnvarchar(100)NullableUser email
AccountUserIdintRequiredWotNot user ID (foreign platform)
DateAddedToAccountdatetime2RequiredWhen added to WotNot
IsDeletedbitRequired, Default: 0Soft delete
IsOfflinebitRequired, Default: 0Agent offline status

Query pattern: GetWebChatUserForMultipleAccounts(userId) — finds all WebChat accounts a user belongs to.

SoproChatActivity (WotNot webhook → CRM mapping)

ColumnTypeConstraintsDescription
IdintPK, IdentityPrimary key
DateCreateddatetime2RequiredWhen webhook was received
ConversationIdnvarchar(128)RequiredWotNot conversation UUID
ProspectIdintNullableMatched Sopro prospect
CompanyDomainnvarchar(max)NullableExtracted from visitor
CompanyIdintNullableMatched Sopro company
SalesAgentIdintNullableAssigned agent (0 = unassigned)
SalesAgentNamenvarchar(128)NullableAgent display name
PhoneNumbernvarchar(512)NullableFrom chat variables
Emailnvarchar(512)NullableFrom chat variables — primary prospect lookup key
Locationnvarchar(512)NullableGeo-location from IP
VisitorNamenvarchar(512)NullableProspect display name
ConversationStartedbitRequiredReal conversation (not just form)
ConversationClosedbitRequiredConversation ended
AccountIdintRequiredWotNot account — maps to WebChatClient.AccountId
CampaignIdintNullableAssigned lead campaign
IsBusinessEmailProvidedbitRequiredNon-public email domain
ConversationOutOfWorkingHoursbitRequiredOutside business hours
ResponseTimetimeNullableAgent response time
FormOnlyResponsebitRequiredNo 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)

ColumnTypeDescription
Idint PKPrimary key
ConversationIdnvarchar(128)WotNot conversation UUID
DateCreateddatetime2Event timestamp
EventTypeint (enum)WebChatEventTypeEnum
MessageTypeint? (enum)WebChatMessageTypeEnum (message events only)
ChatAssignedToUserIdint?Assignee change target
ChatAssignedToBotIdint?Bot assignment target
IsLeadEventbitLead capture event
EmailProvidednvarchar(512)Email captured in variables
PhoneNumberProvidednvarchar(512)Phone captured in variables

Query: GetByConversationIdAsync(conversationId) — ordered by DateCreated, used by TaskSoproChat to track assignee changes.

SoproChatMessage

ColumnTypeDescription
Idint PKPrimary key
SoproChatActivityIdint FKParent conversation
ConversationIdnvarcharWotNot conversation UUID
MessageIdnvarcharWotNot message UUID
SenderTypenvarchar"visitor" | "agent" | "bot"
MessageTypeint (enum)WebChatMessageTypeEnum
Contentnvarchar(max)Message body (text/images/JSON)
DateTimeSentdatetime2When sent

Controllers — Full Action Reference

WebChatController /WebChat — Portal Management

File: Portal.Web/Controllers/WebChatController.csDI: 5 (IWebsiteWidgetService, IWebChatClientService, IWebChatLogService, ISoproChatActivityService, IWebChatApiService forwarded to base) — Auth: [Authorize] + [MobileDeviceFilter]
ActionHTTPRoutePermissionDescription
Index(bool getStarted = false)GET/WebChatMain 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/GetStartedFirst-time setup page. Shows initialization status, error messages.
Settings()GET/WebChat/SettingsWebChat configuration: website URL, script name, personalisation toggle.
Users()GET/WebChat/UsersAgent management: list, add, remove, role assignment, online/offline toggle.
Reports()GET/WebChat/ReportsAnalytics dashboard: conversations, response times, leads, agent performance.
Logs()GET/WebChat/LogsAudit 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.csDI: 1 (IWebChatClientService) — Auth: [Authorize] + [MobileDeviceFilter]
ActionHTTPRoutePermissionDescription
Index(string botId, string conversationId, string accountId)GET/LiveChatAgent 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.csDI: 4 (IWebsiteWidgetService, IProspectIntentTrackingService, IGeneralSettingsService, IWebChatClientService) — Auth: [Authorize]
ActionHTTPDescription
Index()GETWebsite 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.csDI: 17 — Auth: Webhook Custom Authorization (commented: //[WebChatCustomAutorization])
ActionHTTPDescription
Webhook([FromBody] object payload)POSTPrimary 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()GETReturns paginated conversations for the authenticated account.
GetMessages(string conversationId)GETReturns all messages for a conversation, excluding bot messages.
ResolveConversation(int id, string resolution)POSTAuto/manual conversation resolution. Updates ConversationRosolvedDate / ConversationRosolvedByUserDate.
AssignConversation(int id, int userId)POSTAssigns conversation to agent. Updates SalesAgentId.

Webhook Payload Processing:

  1. Parse JSON → extract event, conversation_id, account_id
  2. Get or create SoproChatActivity by ConversationId
  3. Insert SoproChatActivityLog with event type, message type, assignee, variables
  4. If lead event: validate email domain → check PublicEmailDomains → set IsBusinessEmailProvided
  5. If ConversationStarted: enqueue SoProQueueTypeEnum.SoproChat message for async processing
  6. SignalR hub notification to connected clients

SoproChatOldController /api/SoproChatOld — Legacy Webhook (Core)

File: SoproCoreAPI/Controllers/SoproChatOldController.csDI: 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.csDI: 15 — Auth: Public (rate-limited)
ActionHTTPRouteDescription
Hq(string key)GET/script/hq.jsStandard widget script. Delegates to HqInternal(key, false).
HqSpa(string key)GET/script/hqspa.jsSPA variant. Delegates to HqInternal(key, true). Uses CoreSPA view instead of Core.
IpLookup([FromBody] IPLookupModel)POST/script/iplookupIP-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/personalizePersonalisation script view.
Prospect(string prospectToken)GET/script/prospectProspect-specific script view.
Admin(string token, string secret)GET/script/adminAdmin debug script (requires outbaseadmin=1 query param).

HqInternal Processing Pipeline:

  1. Validate PropertySettings by key → 404 if null (or WrongId if validation mode)
  2. Check Active flag → error if inactive client
  3. Domain validation (if _validateobw query param)
  4. Fetch ClientData → check WebChatActive
  5. If WebChatActive: fetch WebChatClientData.ScriptName via IWebChatClientService
  6. Plugin logging (if enabled)
  7. Security checks: Bot detection → Browser check → Rate limiting → IP blacklist
  8. Exclusion/Inclusion rules check
  9. Prospect lookup by _obid (GUID) or _obidt (Base36-encoded int)
  10. If IsFullWebChatPersonalizationActive: enriched prospect data
  11. Script assembly: [Core|CoreSPA] + [WebChatScript] + [Personalize] + [Admin] + [Final]
  12. 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.csPlatform: Core — Input: ConsoleTaskParametars with chatActivityId

Processing Algorithm

  1. Load Activity: _dapperSoproChatActivityService.FindByIdAsync(chatActivityId)
  2. Load Context: Messages → ActivityLogs → WebChatClient (by AccountId) → GeneralSettings
  3. Prospect Resolution:
    • Primary: _dapperProspectService.GetProspectsByEmailAsync(chatActivity.Email) — exact match only if count==1
    • Secondary: Look up by _obid from webhook variables (parsed from JSON)
  4. Company Matching: _dapperCompanyService.GetCompanyByDomainAndClientId(chatActivity.CompanyDomain, webChatClient.ClientId)
  5. Campaign Assignment: webChatClient.Client.WebchatLeadCampaignId → assign to SoproChatActivity.CampaignId
  6. Prospect Creation (if new): build from chat data (name, email, phone, company)
  7. CRM Sync: Trigger _crmSyncTriggerService for prospect + company
  8. Exclusion Check: Validate against exclusion rules (_dapperExclusionService)
  9. Assignee Tracking: Sort AssigneeChange logs by DateCreated → track handoffs
  10. Update Activity: Save resolved SoproChatActivity with all matched data

Error Handling

ConditionResult
Activity not foundSuccess=false, Log: "SoproChatActivity not found for Id={id}"
WebChatClient not foundSuccess=false, Log: "WebChatClient not found for AccountId={id}"
Multiple prospects by emailProspect NOT auto-assigned (ambiguous match)
Company not found by domainProspect created without company link
Any exceptionLogged to ApplicationsErrorLog, Success=false

WotNot API Client — Full Reference

File: Utils/SoProAPI/WebChatApi.csShared across both platforms
MethodSignatureAuthPurpose
TokenTask<string> Token(WebChatClientCredentialsModel)client_id + client_secretGET OAuth token from /oauth/token
TokenSyncstring TokenSync(WebChatClientCredentialsModel)client_id + client_secretSynchronous version (WebClient)
Post<TIn,T>Task<T> Post(credentials, route, TIn)Bearer tokenGeneric 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 tokenGeneric PUT — updates settings
Get<T>Task<T> Get(credentials, route)Bearer tokenGeneric GET — retrieves data
Delete<T>Task<T> Delete(credentials, route)Bearer tokenGeneric DELETE — removes users

Admin API Flow (Feature Configuration)

  1. PostSyncNoToken<AdminLoginModel, UserLoginResponseModel>("/login", model) → gets refresh_token + ingrescookie + session
  2. GetSyncWithCookie<WebchatAdminPropertiesModel>(apiUrl + "/accounts/" + accountId + "/features", cookie) → gets feature containers
  3. Iterate Results[].Features[] → update feature flags → Put back

DI Registration Reference

InterfaceImplementationLifetimePlatformFile
IWebChatClientServiceWebChatClientServiceScopedLegacy PortalSimpleInjectorInitializer.cs
IWebChatClientServiceWebChatClientServiceScopedCore PortalProgram.cs
IDapperWebChatClientServiceDapperWebChatClientServiceScopedCore APISoproCoreAPI/Program.cs
IDapperWebChatClientServiceDapperWebChatClientServiceScopedQueue ConsumerInitServices.cs
ISoproChatActivityServiceSoproChatActivityServiceScopedBoth PortalsSimpleInjector / Program.cs
IWebChatClientServiceWebChatClientService (personalisation)ScopedPersonalisationStartup.cs
IWebChatClientDataWebChatClientDataScopedPersonalisationStartup.cs

Enums — Complete Reference

WebChatEventTypeEnum

ValueNameFriendly Name (WotNot JSON)Usage
1ConversatoinCreate"conversation_create"New chat started. Triggers SoproChatActivity insert + queue.
2Message"message"New message. Logged in SoproChatActivityLog + SoproChatMessage.
3Status"status"Conversation status change (open/closed/resolved).
4AssigneeChange"assignee_change"Agent assignment changed. Tracked for handoff analysis.
5Variables"variables"Session variables updated (email, phone, name captured).
6ConversationLabels"conversation_labels"Labels applied to conversation.
7Note"note"Internal agent note added.

WebChatMessageTypeEnum

ValueNameFriendly NameValueNameFriendly Name
1Texttext12Documentdocument
2Imageimage13Carouselcarousel
3Formform14JavascriptResponsejavascript.response
4FileUploadfile_upload15SliderResponseslider.response
5Calendarcalendar16Sliderslider
6Audioaudio17AppointmentBookingResponseappointment_booking.response
7CalendlyResponsecalendly.response18ButtonResponsebutton.response
8Videovideo19FormResponseform.response
9Listlist20FileUploadResponsefile_upload.response
10Javascriptjavascript21AppointmentBookingappointment_booking
11Buttonbutton

SoproChatActivityTypeEnum

ValueNameFriendly NameDescription
1ChatSession"Webchat session"Live conversation between prospect and agent
2ChatLead"Webchat lead"Lead captured via chat (form, email provided)

Configuration Keys

Key PathTypePlatformDescription
Client.WebChatActiveboolDBMaster switch. Checked by ScriptController.HqInternal.
Client.UseWebChatboolDBLegacy flag. Checked by WebChatController for initialization.
Client.WebchatLeadCampaignIdint?DBCampaign for chat leads. Used by TaskSoproChat.
Client.WebChatInitializedboolSessionRuntime flag. Set after CreateAccountAsync succeeds.
PropertySettings.IsFullWebChatPersonalizationActiveboolDBEnables enriched prospect data in chat widget.
WebChatClient.ScriptNamestringDBWotNot script identifier. Injected as chat widget src.
GeneralSettings.WebChatClientCredentialsobjectDB/SettingsWotNot 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

LayerPatternExample
PersonalisationReturn JavaScript error commentreturn this.JavascriptErrorContent("Invalid Outbase Key")
PortalRedirect with error messagereturn RedirectToAction("GetStarted", new { error = "..." })
Core APILog to ApplicationsErrorLog + return HTTP error_appErrorLogService.InsertAsync(error)
Queue ConsumerConsoleTaskResponce with Success=false + Logret.Success = false; ret.Log = "..."
WotNot APICatch WebException, return default/nullcatch (WebException) { return string.Empty; }

Views & Client-Side

ViewPlatformPurpose
WebChatScript.cshtmlPersonalisationInjects <script src="chat.sopro.io/chat-widget/{ScriptName}.js"> with data-session-payload JSON containing prospect name, email, company, outreach activity, portal URL.
Core.cshtmlPersonalisationMain widget JS. Passes WebChatScriptName, IsWebchatActive to /iplookup. Calls reloadDataSession() when prospect is known and WebChat is active.
CoreSPA.cshtmlPersonalisationSPA variant. Same WebChat logic, adapted for single-page apps.
WebchatMobile.cshtmlBoth PortalsMobile redirect page. Links to iOS app: apps.apple.com/mk/app/sopro-webchat/id6504247831.

AI Conversation Resolution

Config: WebchatConversationResolverAIUsed 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

ResolutionEnum ValueTriggerCRM Action
LeadWebChatConversationResolvedEnum.LeadProspect provided business email + phone, expressed sales interestProspect created/updated in CRM, campaign assigned, CRM sync triggered
SupportWebChatConversationResolvedEnum.SupportProspect asked a support question, no sales intent detectedSupport notification email sent, no lead created
RemovalWebChatConversationResolvedEnum.RemovalProspect explicitly asked to be removed from contactAuto-exclusion created for the prospect email/domain
InvalidWebChatConversationResolvedEnum.InvalidNo usable contact details, spam, or test conversationLogged only, no CRM action

AI Configuration

Config KeyPurpose
WebchatConversationResolverAI:ApiUrlAI resolver endpoint URL
WebchatConversationResolverAI:BearerTokenAuthentication bearer token
WebchatConversationResolverAI:ModelAI model identifier (e.g. GPT-4)
WebchatConversationResolverAI:ResponseFormatTypeExpected response format (JSON schema)
WebchatConversationResolverAI:MessageContentSystem 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 transcript
to 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):

  1. Concatenate all visitor messages into a single text blob
  2. POST to PhoneNumberResponderAI:ApiUrl with authorization header
  3. Parse response for: Emails[] (with IsBusinessEmail flag), Numbers[] (phone numbers), names
  4. Filter out emails matching the client's own domain (prevent self-leads)
  5. Set IsBusinessEmailProvided flag — required for Lead resolution

SignalR Real-Time Notifications

Hub: SoproCoreAPI/Hubs/NotificationsHub.csUsed by: SoproChatController webhook handler

When WotNot sends a webhook event, the controller broadcasts real-time updates to connected agents via SignalR:

EventSignalR EventTypeRecipientPurpose
AssigneeChange (auto-assign)"start"Specific agent emailNew conversation assigned — agent sees popup in Live Chat
AssigneeChange (unassign)"stop"All agents in accountConversation unassigned — remove from agent's active list
AssigneeChange (no email)"start"All agents in accountBroadcast — all agents see new unassigned conversation
First visitor message"start"All agents in accountNew 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

TemplateSendGrid EnumSubjectTrigger
Out of Working Hours LeadOutOfWorkingHoursLeadTemplateIdVia SendGrid templateAI 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():

VariableSourceExample Value
visitor_idchatActivity.VisitorName"John Smith" or "Visitor"
emailchatActivity.Email"john@acmecorp.com" or "Undefined"
phonechatActivity.PhoneNumber"+44 7911 123456" or "Undefined"
prospect_emailMatched prospect email"john.smith@acmecorp.com" or "Undefined"
company_domainchatActivity.CompanyDomain"acmecorp.com" or "Undefined"
campaign_nameAssigned campaign"Acme Outreach Q3" or "Undefined"
is_prospect_identifiedbooltrue/false
is_company_identifiedbooltrue/false
is_campaign_identifiedbooltrue/false
is_email_leadbooltrue/false
is_phone_leadbooltrue/false
bot_idWotNot bot ID12345
conversation_keyconversationId"conv_7a3f9b2c"
account_idchatActivity.AccountId67890
subjectDynamic"Sopro WebChat: Lead"
is_support_form_responsebooltrue if FormOnlyResponse + Support
is_leadbooltrue/false
is_supportbooltrue/false
is_removalbooltrue/false

Notification Gating Conditions

Notifications are only sent when ALL of these conditions are met:

  1. AI resolution is Lead, Support, or Removal (not Invalid)
  2. If Lead: a business email was provided (personal emails like Gmail skip notification)
  3. 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 --> DB

Path 1: Message Events → SoproChatMessage

Trigger: WotNot webhook with event == "message".

StepCodeDescription
1. ParsemessageTypeStr = wchEvent?.Event?.Payload?.Message?.TypeExtract message type (text, image, form, etc.)
2. FormatmessageText = wchEvent?.Event?.Payload?.Message?.TextFor FormResponse: iterates Payload.Message.Payload.Fields[], builds "Label - Value, " concatenated string. For all other types: takes Message.Text directly.
3. Lookup ActivityGetSoproChatActivityByConversationIdAsync(conversationId)Find existing SoproChatActivity row for this conversation
4a. Existing conversationnew DapperSoproChatMessage { SoproChatActivityId, DateTimeSent, SenderType, ParticipantName, MessageType, MessageText }CreateAsync()Append message to existing conversation. SenderType = "visitor" | "user" | "bot".
4b. First messageCreate SoproChatActivity + then create SoproChatMessage + SignalR broadcastOnly 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".

StepCodeDescription
1. Create Lognew DapperSoproChatActivityLog { ConversationId, DateCreated=UtcNow, EventType=AssigneeChange }Create activity log entry
2. Resolve TargetwchEvent?.Event?.Payload?.To?.TypeIf "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".

StepCodeDescription
1. Log Eventnew DapperSoproChatActivityLog { ConversationId, EventType=Status }CreateAsync()INSERT close event into ActivityLog
2. Extract VariableswchEvent?.Event?.Payload?.VariablesExtract visitor_prospect_obid, visitor_company_domain, visitor_prospect_email, country_name, city_name, visitor_name
3. Update ActivityUpdate SoproChatActivity row: ConversationClosed=true, ConversationCloseTime, extracted variablesFinalize the conversation record
4. EnqueueCreate SoProQueueMessage with QueueType=WebChatProcessingQueue the conversation for async TaskSoproChat processing (prospect matching, CRM sync, campaign assignment)

Message Data Flow Diagram

flowchart TD WN["WotNot Webhook
POST /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

ServiceBase ClassCustom Methods
DapperSoproChatMessageServiceDapperRepositoryBaseRepository<DapperSoproChatMessage>GetBySoproChatActivityIdAsync(int) — SELECT * ORDER BY DateTimeSent
DapperSoproChatActivityLogServiceDapperRepositoryBaseRepository<DapperSoproChatActivityLog>GetByConversationIdAsync(string) — SELECT * ORDER BY DateCreated
DapperSoproChatActivityServiceDapperRepositoryBaseRepository<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

PropertyValue
ProjectSoProQueue/Consoles/SoProQueueConsumer/SoProQueueConsumer.csproj
Entry PointProgram.Main(string[] args)static async Task<int>
Platform.NET 6 Console Application
DeploymentWindows Service via SoProQueueConsoleManager
LifecycleRuns 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

FieldValue
Queue NameDerived from ConsumerHelper.GetQueueForConsole(WebChatProcessing)
Message FormatSoProQueueMessages.Message = chatActivityId (int as string)
Task MethodTaskSoproChat(ConsoleTaskParametars)ConsoleTaskResponce
Entry in switchcase ConsoleTypeEnum.WebChatProcessing: retTask = await TaskSoproChat(...)

SoProQueueConsoleManager

PropertyValue
ProjectSoProQueue/Consoles/SoProQueueConsoleManager/
PurposeManages console lifecycle — spawns, monitors, and restarts SoProQueueConsumer instances per queue type. Ensures exactly N instances of WebChatProcessing console are always running.
Key BehaviorSpawns 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

  1. Enqueue: SoproChatController.Webhook() creates SoProQueueMessages row + pushes message ID to Azure Storage Queue
  2. Dequeue: SoProQueueConsumer.ProcessConsole() polls Azure Queue via _soProQueueService.GetNext(queue)
  3. Lookup: Message body = soProQueueMessagesId (int). Fetch full SoProQueueMessages row from DB.
  4. Dispatch: switch(enumSelected)TaskSoproChat(new ConsoleTaskParametars { Message = soProQueueMessage.Message })
  5. Process: TaskSoproChat resolves prospect, company, campaign, CRM sync. Returns ConsoleTaskResponce { Success, Completed, Log }
  6. Log: SoProQueueLog row inserted with success/failure. SoProQueueMessages row updated with end time, computer ID.
  7. Delete: Azure queue message deleted. If TaskSoproChat fails → message left in queue for retry.