Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 11 additions & 4 deletions Core/Resgrid.Config/ChatConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,24 @@ public static class ChatConfig
/// <summary>Novu workflow triggered for realtime chat message push notifications.</summary>
public static string NovuChatWorkflowId = "user-chat-message";

/// <summary>GIF search provider: "giphy" or "tenor". Empty disables GIF search.</summary>
/// <summary>
/// GIF search provider: "giphy" is the only supported value (Tenor stopped accepting new API
/// clients in January 2026 and was removed). Empty disables GIF search.
/// </summary>
public static string GifProvider = "";
public static string GiphyApiKey = "";
public static string TenorApiKey = "";

/// <summary>
/// Giphy content-rating cap for GIF search/trending results. Allowed values: "g", "pg",
/// "pg-13" — anything else (including "r") falls back to the workplace-safe default "g".
/// </summary>
public static string GifRating = "g";

/// <summary>Allowed CDN hosts for GIF message metadata urls (https only); anything else is dropped server-side.</summary>
public static string[] GifCdnHosts = new[]
{
"giphy.com", "i.giphy.com", "media.giphy.com",
"media0.giphy.com", "media1.giphy.com", "media2.giphy.com", "media3.giphy.com", "media4.giphy.com",
"tenor.com", "media.tenor.com", "c.tenor.com"
"media0.giphy.com", "media1.giphy.com", "media2.giphy.com", "media3.giphy.com", "media4.giphy.com"
};

public static int MaxMessageLength = 4000;
Expand Down
4 changes: 2 additions & 2 deletions Core/Resgrid.Model/Providers/IGifProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ public class GifSearchResult
}

/// <summary>
/// Server-side GIF search proxy (Giphy or Tenor per ChatConfig.GifProvider) so provider API keys
/// never ship to clients.
/// Server-side GIF search proxy (Giphy, per ChatConfig.GifProvider) so provider API keys never
/// ship to clients. Results are capped to a workplace-safe content rating (ChatConfig.GifRating).
/// </summary>
public interface IGifProvider
{
Expand Down
4 changes: 3 additions & 1 deletion Core/Resgrid.Services/ChatMessageService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -640,7 +640,9 @@ private async Task ProvisionAcksAsync(ChatChannel channel, ChatMessage message,
RequiredOn = message.SentOn
}), cancellationToken);

PublishEvent(channel, ChatEventKinds.AckRequired, new { message.ChatMessageId, message.ChatChannelId, message.MessageSeq, RequiredCount = requiredUserIds.Count });
// SenderUserId lets clients skip the "acknowledge" banner for the sender's own message
// (the sender has no ack row — see requiredUserIds above).
PublishEvent(channel, ChatEventKinds.AckRequired, new { message.ChatMessageId, message.ChatChannelId, message.MessageSeq, RequiredCount = requiredUserIds.Count, message.SenderUserId });
}

private async Task SaveEditHistoryAsync(ChatMessage message, ChatMessageEditType editType, string byUserId, CancellationToken cancellationToken)
Expand Down
60 changes: 16 additions & 44 deletions Providers/Resgrid.Providers.Messaging/GifProvider.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
namespace Resgrid.Providers.Messaging
{
/// <summary>
/// GIF search proxy for chat. Talks to Giphy or Tenor (per ChatConfig.GifProvider) server-side so
/// the API key never reaches clients. Failures return empty result sets — GIF search is never a
/// hard dependency.
/// GIF search proxy for chat. Talks to Giphy server-side so the API key never reaches clients.
/// Results are capped to a workplace-safe content rating (ChatConfig.GifRating, "g" by default).
/// Failures return empty result sets — GIF search is never a hard dependency.
/// </summary>
public class GifProvider : IGifProvider
{
Expand All @@ -26,6 +26,9 @@ public class GifProvider : IGifProvider
private static readonly TimeSpan SearchCacheDuration = TimeSpan.FromSeconds(60);
private const int MaxOffset = 5000;

// Giphy content ratings this proxy will ever request; "r" is deliberately not allowed.
private static readonly string[] AllowedRatings = { "g", "pg", "pg-13" };

private readonly ICacheProvider _cacheProvider;

public GifProvider(ICacheProvider cacheProvider)
Expand All @@ -40,9 +43,6 @@ public bool IsConfigured
if (string.Equals(ChatConfig.GifProvider, "giphy", StringComparison.OrdinalIgnoreCase))
return !string.IsNullOrWhiteSpace(ChatConfig.GiphyApiKey);

if (string.Equals(ChatConfig.GifProvider, "tenor", StringComparison.OrdinalIgnoreCase))
return !string.IsNullOrWhiteSpace(ChatConfig.TenorApiKey);

return false;
}
}
Expand All @@ -54,16 +54,13 @@ public async Task<List<GifSearchResult>> SearchAsync(string query, int limit, in

// Short per-query cache: identical searches are common (picker re-open, scroll re-fetch)
// and each uncached call burns provider API quota.
var cacheKey = $"gifsearch:{ChatConfig.GifProvider?.ToLowerInvariant()}:{query.Trim().ToLowerInvariant()}:{Clamp(limit)}:{ClampOffset(offset)}";
var cacheKey = $"gifsearch:giphy:{Rating()}:{query.Trim().ToLowerInvariant()}:{Clamp(limit)}:{ClampOffset(offset)}";

async Task<List<GifSearchResult>> search()
{
try
{
if (string.Equals(ChatConfig.GifProvider, "tenor", StringComparison.OrdinalIgnoreCase))
return await TenorRequestAsync($"https://tenor.googleapis.com/v2/search?q={Uri.EscapeDataString(query)}&key={ChatConfig.TenorApiKey}&limit={Clamp(limit)}&pos={ClampOffset(offset)}");

return await GiphyRequestAsync($"https://api.giphy.com/v1/gifs/search?api_key={ChatConfig.GiphyApiKey}&q={Uri.EscapeDataString(query)}&limit={Clamp(limit)}&offset={ClampOffset(offset)}&rating=pg-13");
return await GiphyRequestAsync($"https://api.giphy.com/v1/gifs/search?api_key={ChatConfig.GiphyApiKey}&q={Uri.EscapeDataString(query)}&limit={Clamp(limit)}&offset={ClampOffset(offset)}&rating={Rating()}");
}
catch (Exception ex)
{
Expand All @@ -85,10 +82,7 @@ public async Task<List<GifSearchResult>> TrendingAsync(int limit)

try
{
if (string.Equals(ChatConfig.GifProvider, "tenor", StringComparison.OrdinalIgnoreCase))
return await TenorRequestAsync($"https://tenor.googleapis.com/v2/featured?key={ChatConfig.TenorApiKey}&limit={Clamp(limit)}");

return await GiphyRequestAsync($"https://api.giphy.com/v1/gifs/trending?api_key={ChatConfig.GiphyApiKey}&limit={Clamp(limit)}&rating=pg-13");
return await GiphyRequestAsync($"https://api.giphy.com/v1/gifs/trending?api_key={ChatConfig.GiphyApiKey}&limit={Clamp(limit)}&rating={Rating()}");
}
catch (Exception ex)
{
Expand All @@ -97,6 +91,13 @@ public async Task<List<GifSearchResult>> TrendingAsync(int limit)
}
}

/// <summary>Sanitized content rating: only g/pg/pg-13 ever reach the provider; default "g".</summary>
private static string Rating()
{
var rating = ChatConfig.GifRating?.Trim().ToLowerInvariant();
return AllowedRatings.Contains(rating) ? rating : "g";
}

// Belt-and-suspenders scrub for key=/api_key= query params. A bounded match timeout caps regex work
// on pathological input (ReDoS guard); on timeout the literal-key replacements have already removed
// the real secrets, so falling through without the query-param scrub is safe.
Expand All @@ -114,9 +115,6 @@ private static void LogSanitizedException(Exception ex)
if (!string.IsNullOrWhiteSpace(ChatConfig.GiphyApiKey))
text = text.Replace(ChatConfig.GiphyApiKey, "***");

if (!string.IsNullOrWhiteSpace(ChatConfig.TenorApiKey))
text = text.Replace(ChatConfig.TenorApiKey, "***");

try
{
text = KeyQueryParamRegex.Replace(text, "$1=***");
Expand Down Expand Up @@ -148,32 +146,6 @@ private static async Task<List<GifSearchResult>> GiphyRequestAsync(string url)
.ToList();
}

private static async Task<List<GifSearchResult>> TenorRequestAsync(string url)
{
var json = await _httpClient.GetStringAsync(url);
var payload = JObject.Parse(json);

return (payload["results"] as JArray ?? new JArray())
.Select(item =>
{
var gif = item.SelectToken("media_formats.gif") ?? item.SelectToken("media_formats.tinygif");
var preview = item.SelectToken("media_formats.tinygif") ?? gif;
var dims = gif?["dims"] as JArray;

return new GifSearchResult
{
Id = (string)item["id"],
Title = (string)item["title"] ?? (string)item["content_description"],
PreviewUrl = (string)preview?["url"],
GifUrl = (string)gif?["url"],
Width = dims != null && dims.Count > 0 ? ParseInt(dims[0]) : 0,
Height = dims != null && dims.Count > 1 ? ParseInt(dims[1]) : 0
};
})
.Where(r => !string.IsNullOrWhiteSpace(r.GifUrl) && IsAllowedCdnUrl(r.GifUrl) && IsAllowedCdnUrl(r.PreviewUrl))
.ToList();
}

private static bool IsAllowedCdnUrl(string url)
{
if (string.IsNullOrWhiteSpace(url))
Expand Down
58 changes: 53 additions & 5 deletions Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ public class ChatController : V4AuthenticatedApiControllerbase
private readonly ICacheProvider _cacheProvider;
private readonly IEventAggregator _eventAggregator;
private readonly IQueueService _queueService;
private readonly IUserProfileService _userProfileService;

public ChatController(
IChatChannelService chatChannelService,
Expand All @@ -66,7 +67,8 @@ public ChatController(
IAuthorizationService authorizationService,
ICacheProvider cacheProvider,
IEventAggregator eventAggregator,
IQueueService queueService)
IQueueService queueService,
IUserProfileService userProfileService)
{
_chatChannelService = chatChannelService;
_chatPermissionService = chatPermissionService;
Expand All @@ -81,6 +83,7 @@ public ChatController(
_cacheProvider = cacheProvider;
_eventAggregator = eventAggregator;
_queueService = queueService;
_userProfileService = userProfileService;
}

#endregion Members and Constructors
Expand Down Expand Up @@ -770,6 +773,17 @@ public async Task<ActionResult<ChatMessageSentResult>> SendMessage(string channe
if (await IsRateLimitedAsync("send", ChatConfig.SendRateLimitPerWindow))
return RateLimitedResult<ChatMessageSentResult>();

// Assistant conversations are plain text only: no threads, no attachments/GIFs, no urgent
// priority. Enforced here so every client (web and mobile) gets the same behavior.
var channel = await _chatChannelService.GetChannelByIdAsync(channelId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules high

Unguarded external call: GetChannelByIdAsync is not wrapped in try/catch, violating Rule [27]. Wrap the call in try/catch, log with channelId context, and return an appropriate error ActionResult.

Kody rule violation: Add try-catch blocks for external calls

Prompt for LLM

File Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs:

Line 778:

Unguarded external call: GetChannelByIdAsync is not wrapped in try/catch, violating Rule [27]. Wrap the call in try/catch, log with channelId context, and return an appropriate error ActionResult.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

var isChatbotChannel = channel != null && channel.ChannelType == (int)ChatChannelType.Chatbot;
if (isChatbotChannel && ((ChatMessageType)input.MessageType != ChatMessageType.Text || !String.IsNullOrWhiteSpace(input.ThreadRootMessageId)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules low

Unsafe type casting: (ChatMessageType)input.MessageType uses a direct cast instead of the as operator or pattern matching. Replace with safe casting and guard null results before usage.

Kody rule violation: Use safe type casting with as operator

Prompt for LLM

File Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs:

Line 780:

Unsafe type casting: `(ChatMessageType)input.MessageType` uses a direct cast instead of the `as` operator or pattern matching. Replace with safe casting and guard null results before usage.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

return BadRequest("Assistant conversations only support plain text messages.");

// Urgent (ack-required) priority is a channel-level broadcast concept; thread replies
// always send as normal priority no matter what the client asked for.
var isThreadReply = !String.IsNullOrWhiteSpace(input.ThreadRootMessageId);

var request = new ChatMessageSendRequest
{
ChatChannelId = channelId,
Expand All @@ -778,7 +792,7 @@ public async Task<ActionResult<ChatMessageSentResult>> SendMessage(string channe
AsIncidentCommander = input.AsIncidentCommander,
Body = input.Body,
MessageType = (ChatMessageType)input.MessageType,
Priority = (ChatMessagePriority)input.Priority,
Priority = isChatbotChannel || isThreadReply ? ChatMessagePriority.Normal : (ChatMessagePriority)input.Priority,
ThreadRootMessageId = input.ThreadRootMessageId,
AlsoSendToChannel = input.AlsoSendToChannel,
ClientMessageId = input.ClientMessageId,
Expand Down Expand Up @@ -823,8 +837,7 @@ public async Task<ActionResult<ChatMessageSentResult>> SendMessage(string channe
// The assistant channel is also reachable from the regular chat page, so sends that arrive
// through this generic endpoint must still feed the chatbot pipeline (the dedicated
// ChatbotController.SendChatMessage does the same for the assistant panel).
var channel = await _chatChannelService.GetChannelByIdAsync(channelId);
if (channel != null && channel.ChannelType == (int)ChatChannelType.Chatbot && !String.IsNullOrWhiteSpace(message.Body))
if (isChatbotChannel && !String.IsNullOrWhiteSpace(message.Body))
{
var queued = await _queueService.EnqueueChatbotMessageAsync(new Resgrid.Model.Queue.ChatbotMessageQueueItem
{
Expand Down Expand Up @@ -916,6 +929,9 @@ public async Task<ActionResult<ChatActionResult>> DeleteMessage(string messageId
if (!await ChatEnabledAsync())
return NotFound();

if (await IsChatbotMessageChannelAsync(messageId))
return BadRequest("Messages can't be deleted in assistant conversations.");
Comment on lines +932 to +933

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Security high

Information disclosure vulnerability in DeleteMessage: IsChatbotMessageChannelAsync is called before any department or ownership validation, returning a distinguishable BadRequest ("Messages can't be deleted in assistant conversations.") for chatbot channels versus a generic Failure for non-chatbot — allowing any authenticated user to probe arbitrary message IDs and learn which belong to assistant conversations. Add CheckMessageChannelAccessAsync before the chatbot-type guard so cross-department messages return a uniform NotFound.

var accessCheck = await CheckMessageChannelAccessAsync(messageId);
if (accessCheck != null)
    return accessCheck;

if (await IsChatbotMessageChannelAsync(messageId))
    return BadRequest("Messages can't be deleted in assistant conversations.");
Prompt for LLM

File Web/Resgrid.Web.Services/Controllers/v4/ChatController.cs:

Line 932 to 933:

Information disclosure vulnerability in DeleteMessage: IsChatbotMessageChannelAsync is called before any department or ownership validation, returning a distinguishable BadRequest ("Messages can't be deleted in assistant conversations.") for chatbot channels versus a generic Failure for non-chatbot — allowing any authenticated user to probe arbitrary message IDs and learn which belong to assistant conversations. Add CheckMessageChannelAccessAsync before the chatbot-type guard so cross-department messages return a uniform NotFound.

Suggested Code:

var accessCheck = await CheckMessageChannelAccessAsync(messageId);
if (accessCheck != null)
    return accessCheck;

if (await IsChatbotMessageChannelAsync(messageId))
    return BadRequest("Messages can't be deleted in assistant conversations.");

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


var result = new ChatActionResult();
result.Success = await _chatMessageService.DeleteMessageAsync(messageId, UserId, false, null, cancellationToken);
result.Status = result.Success ? ResponseHelper.Deleted : ResponseHelper.Failure;
Expand Down Expand Up @@ -957,6 +973,9 @@ public async Task<ActionResult<ChatActionResult>> AddReaction(string messageId,
if (accessCheck != null)
return accessCheck;

if (await IsChatbotMessageChannelAsync(messageId))
return BadRequest("Reactions aren't available in assistant conversations.");

var result = new ChatActionResult();
result.Success = await _chatMessageService.AddReactionAsync(messageId, UserId, null, input.Emoji, cancellationToken);
result.Status = result.Success ? ResponseHelper.Created : ResponseHelper.Failure;
Expand Down Expand Up @@ -988,6 +1007,9 @@ public async Task<ActionResult<ChatActionResult>> RemoveReaction(string messageI
if (accessCheck != null)
return accessCheck;

if (await IsChatbotMessageChannelAsync(messageId))
return BadRequest("Reactions aren't available in assistant conversations.");

var result = new ChatActionResult();
result.Success = await _chatMessageService.RemoveReactionAsync(messageId, UserId, null, emoji, cancellationToken);
result.Status = result.Success ? ResponseHelper.Deleted : ResponseHelper.Failure;
Expand Down Expand Up @@ -1055,9 +1077,18 @@ public async Task<ActionResult<GetChatAcksResult>> GetAcks(string messageId)

if (acks != null && acks.Any())
{
var profiles = await _userProfileService.GetSelectedUserProfilesAsync(acks.Select(a => a.UserId).Distinct().ToList());
var namesByUserId = (profiles ?? new List<UserProfile>())
.Where(p => p?.UserId != null)
.ToDictionary(p => p.UserId, p => p.FullName?.AsFirstNameLastName, StringComparer.OrdinalIgnoreCase);

foreach (var ack in acks)
{
result.Data.Add(ConvertAckResultData(ack));
var data = ConvertAckResultData(ack);
if (ack.UserId != null && namesByUserId.TryGetValue(ack.UserId, out var name))
data.DisplayName = name;

result.Data.Add(data);
}

result.PageSize = result.Data.Count;
Expand Down Expand Up @@ -1254,6 +1285,9 @@ public async Task<ActionResult<ChatAttachmentUploadedResult>> UploadAttachment(s
if (channel == null || channel.DepartmentId != DepartmentId)
return NotFound();

if (channel.ChannelType == (int)ChatChannelType.Chatbot)
return BadRequest("Attachments aren't available in assistant conversations.");

if (!await _chatPermissionService.CanPostAsync(channel, UserId, null))
return Unauthorized();

Expand Down Expand Up @@ -1553,6 +1587,20 @@ private async Task<bool> IsRateLimitedAsync(string action, int limitPerWindow)
return StatusCode(StatusCodes.Status429TooManyRequests, result);
}

/// <summary>
/// True when the message lives in an assistant (chatbot) conversation, where reactions,
/// threads and deletes are not available.
/// </summary>
private async Task<bool> IsChatbotMessageChannelAsync(string messageId)
{
var message = await _chatMessageService.GetMessageByIdAsync(messageId);
if (message == null)
return false;

var channel = await _chatChannelService.GetChannelByIdAsync(message.ChatChannelId);
return channel != null && channel.ChannelType == (int)ChatChannelType.Chatbot;
}

/// <summary>
/// Verifies the message exists in this department and the user can access its channel.
/// Returns null when access is allowed, otherwise the error result to return.
Expand Down
8 changes: 8 additions & 0 deletions Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -447,6 +447,14 @@ public async Task<ActionResult<ChatbotSessionResetResult>> NewChatSession()
if (session != null)
await _chatbotSessionManager.EndSessionAsync(session.SessionId);

// Visible confirmation in the conversation (fans out over SignalR to every client);
// without it the reset is silent and looks like the button did nothing.
var channel = await _chatChannelService.EnsureChatbotChannelAsync(DepartmentId, UserId);
if (channel != null)
await _chatMessageService.SendBotMessageAsync(channel.ChatChannelId,
DepartmentId.ToString(System.Globalization.CultureInfo.InvariantCulture),
"Starting a new conversation — your previous context has been cleared.", "Resgrid Assistant");
Comment on lines +452 to +456

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Bug medium

State inconsistency in the reset flow: SendBotMessageAsync runs inside the same try/catch as EndSessionAsync, so a transient failure in the confirmation send returns BadRequest even though the session was already cleared — contradicting the persisted state and causing a duplicate 'Starting a new conversation' bot message on retry. Isolate the confirmation send in its own best-effort try/catch that logs but does not fail the reset.

if (session != null)
    await _chatbotSessionManager.EndSessionAsync(session.SessionId);

// Confirmation is best-effort: a failure here must not turn a successful reset into a failure.
try
{
    var channel = await _chatChannelService.EnsureChatbotChannelAsync(DepartmentId, UserId);
    if (channel != null)
        await _chatMessageService.SendBotMessageAsync(channel.ChatChannelId,
            DepartmentId.ToString(System.Globalization.CultureInfo.InvariantCulture),
            "Starting a new conversation — your previous context has been cleared.", "Resgrid Assistant");
}
catch (Exception ex)
{
    Logging.LogException(ex);
}

var result = new ChatbotSessionResetResult { Success = true, ... };
Prompt for LLM

File Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs:

Line 452 to 456:

State inconsistency in the reset flow: SendBotMessageAsync runs inside the same try/catch as EndSessionAsync, so a transient failure in the confirmation send returns BadRequest even though the session was already cleared — contradicting the persisted state and causing a duplicate 'Starting a new conversation' bot message on retry. Isolate the confirmation send in its own best-effort try/catch that logs but does not fail the reset.

Suggested Code:

if (session != null)
    await _chatbotSessionManager.EndSessionAsync(session.SessionId);

// Confirmation is best-effort: a failure here must not turn a successful reset into a failure.
try
{
    var channel = await _chatChannelService.EnsureChatbotChannelAsync(DepartmentId, UserId);
    if (channel != null)
        await _chatMessageService.SendBotMessageAsync(channel.ChatChannelId,
            DepartmentId.ToString(System.Globalization.CultureInfo.InvariantCulture),
            "Starting a new conversation — your previous context has been cleared.", "Resgrid Assistant");
}
catch (Exception ex)
{
    Logging.LogException(ex);
}

var result = new ChatbotSessionResetResult { Success = true, ... };

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules low

Hardcoded bot identity: the display name "Resgrid Assistant" and the confirmation message are inlined, risking drift across chatbot message paths when the brand or name changes. Extract the bot name to a shared constant (e.g., ChatbotConstants.AssistantName) and centralize reusable message templates.

Kody rule violation: Centralize string constants

Prompt for LLM

File Web/Resgrid.Web.Services/Controllers/v4/ChatbotController.cs:

Line 456:

Hardcoded bot identity: the display name "Resgrid Assistant" and the confirmation message are inlined, risking drift across chatbot message paths when the brand or name changes. Extract the bot name to a shared constant (e.g., ChatbotConstants.AssistantName) and centralize reusable message templates.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.


var result = new ChatbotSessionResetResult
{
Success = true,
Expand Down
5 changes: 5 additions & 0 deletions Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs
Original file line number Diff line number Diff line change
Expand Up @@ -741,6 +741,11 @@ public class ChatAckResultData
/// When the user acknowledged (null = still pending)
/// </summary>
public DateTime? AcknowledgedOn { get; set; }

/// <summary>
/// Display name of the user the acknowledgment is required from (populated by GetAcks)
/// </summary>
public string DisplayName { get; set; }
Comment on lines +745 to +748

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Populate DisplayName in the GetAcks response mapper.

ChatController, Lines 1815-1824, creates ChatAckResultData without assigning DisplayName. The client then falls back to UserId, so acknowledgment status does not show recipient names. Resolve each acknowledgment user profile and assign DisplayName in that mapper.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs` around lines 745 -
748, Update the ChatController GetAcks response mapper to resolve each
acknowledgment’s user profile and assign the profile’s display name to
ChatAckResultData.DisplayName when constructing the result. Preserve the
existing UserId mapping and ensure the populated name is returned for each
acknowledgment.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

kody code-review Kody Rules low

Uninitialized auto-property: DisplayName has no default value, risking null references per Rule [31]. Initialize it with = string.Empty.

Kody rule violation: Initialize properties with default values

Prompt for LLM

File Web/Resgrid.Web.Services/Models/v4/Chat/ChatApiModels.cs:

Line 748:

Uninitialized auto-property: `DisplayName` has no default value, risking null references per Rule [31]. Initialize it with `= string.Empty`.

Talk to Kody by mentioning @kody

Was this suggestion helpful? React with 👍 or 👎 to help Kody learn from this interaction.

}

/// <summary>
Expand Down
11 changes: 11 additions & 0 deletions Web/Resgrid.Web.Services/Resgrid.Web.Services.xml
Original file line number Diff line number Diff line change
Expand Up @@ -734,6 +734,12 @@
<param name="input">Reason and optional note for the flag</param>
<returns>ChatActionResult indicating whether the flag was recorded</returns>
</member>
<member name="M:Resgrid.Web.Services.Controllers.v4.ChatController.IsChatbotMessageChannelAsync(System.String)">
<summary>
True when the message lives in an assistant (chatbot) conversation, where reactions,
threads and deletes are not available.
</summary>
</member>
<member name="M:Resgrid.Web.Services.Controllers.v4.ChatController.CheckMessageChannelAccessAsync(System.String)">
<summary>
Verifies the message exists in this department and the user can access its channel.
Expand Down Expand Up @@ -7663,6 +7669,11 @@
When the user acknowledged (null = still pending)
</summary>
</member>
<member name="P:Resgrid.Web.Services.Models.v4.Chat.ChatAckResultData.DisplayName">
<summary>
Display name of the user the acknowledgment is required from (populated by GetAcks)
</summary>
</member>
<member name="T:Resgrid.Web.Services.Models.v4.Chat.ChatFlagResultData">
<summary>
A user report ("flag") of a chat message
Expand Down
Loading
Loading