Skip to content
Merged
29 changes: 29 additions & 0 deletions src/server/HSMServer/Controllers/NotificationsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,35 @@ public async Task<IActionResult> SendTestSlackMessage([FromQuery] Guid id)
return Ok();
}

[HttpPost]
[TelegramRoleFilterById(nameof(id), ProductRoleEnum.ProductManager)]
public async Task<IActionResult> RemoveTelegramBinding(Guid id) =>
await ClearChannel(id, clearTelegram: true);

[HttpPost]
[TelegramRoleFilterById(nameof(id), ProductRoleEnum.ProductManager)]
public async Task<IActionResult> ClearSlackWebhook(Guid id) =>
await ClearChannel(id, clearSlack: true);

[HttpPost]
[TelegramRoleFilterById(nameof(id), ProductRoleEnum.ProductManager)]
public async Task<IActionResult> ClearMattermostWebhook(Guid id) =>
await ClearChannel(id, clearMattermost: true);


private async Task<IActionResult> ClearChannel(Guid id, bool clearTelegram = false, bool clearSlack = false, bool clearMattermost = false)
{
var update = new ChatUpdate
{
Id = id,
ClearTelegramBinding = clearTelegram,
ClearSlackWebhook = clearSlack,
ClearMattermostWebhook = clearMattermost,
};

return await ChatsManager.TryUpdate(update) ? Ok() : NotFound();
}


private ChatFoldersViewModel BuildChatFolders(Chat chat)
{
Expand Down
21 changes: 19 additions & 2 deletions src/server/HSMServer/Notifications/Chats/Chat.cs
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@


// Telegram (optional)
public ChatId? TelegramChatId { get; private set; }

Check warning on line 30 in src/server/HSMServer/Notifications/Chats/Chat.cs

View workflow job for this annotation

GitHub Actions / integration-tests

The annotation for nullable reference types should only be used in code within a '#nullable' annotations context.

public ConnectedChatType? TelegramType { get; init; }
// init would block the ClearTelegramBinding path — ApplyUpdate needs to null these out so
// ToEntity() drops them on the next round-trip. internal set keeps existing object
// initializers in ChatsManager.TryConnect and ChatsManagerTests working; outside this
// assembly the surface is effectively read-only.
public ConnectedChatType? TelegramType { get; internal set; }

public DateTime? AuthorizationTime { get; init; }
public DateTime? AuthorizationTime { get; internal set; }


// Slack (optional)
Expand Down Expand Up @@ -98,6 +102,19 @@

if (update.TelegramChatId.HasValue)
TelegramChatId = new ChatId(update.TelegramChatId.Value);

if (update.ClearTelegramBinding is true)
{
TelegramChatId = null;
TelegramType = null;
AuthorizationTime = null;
}

if (update.ClearSlackWebhook is true)
SlackWebhookUrl = null;

if (update.ClearMattermostWebhook is true)
MattermostWebhookUrl = null;
}

public override ChatEntity ToEntity()
Expand Down
12 changes: 12 additions & 0 deletions src/server/HSMServer/Notifications/Chats/ChatUpdate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,17 @@ public record ChatUpdate : BaseUpdateRequest

// Mattermost (optional)
public string MattermostWebhookUrl { get; init; }


// Negative-clear flags. Chat.ApplyUpdate uses `?? current` for SlackWebhookUrl /
// MattermostWebhookUrl, so a null update value means "don't change" and submitting an
// empty string through EditChat cannot clear a saved webhook. Telegram binding is even
// stricter — TelegramType / AuthorizationTime are internal-set and only mutable here.
// These flags route around both issues by explicitly signalling "set this channel to null".
public bool? ClearTelegramBinding { get; init; }

public bool? ClearSlackWebhook { get; init; }

public bool? ClearMattermostWebhook { get; init; }
}
}
202 changes: 150 additions & 52 deletions src/server/HSMServer/Views/Notifications/EditChat.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,15 @@
var icons = Model.ChatBrandIcons();
var isNewChat = Model.Id == Guid.Empty;
var formAction = isNewChat ? nameof(NotificationsController.AddChat) : nameof(NotificationsController.EditChat);

// First configured channel wins; for a brand-new chat we land on Slack because Telegram can
// only be added through the bot-invite flow (Show setup help), so Slack is the only fillable
// field. An existing chat with no channels configured (e.g. right after ClearSlackWebhook)
// also falls back to Slack — landing on Telegram would surprise the user, who just acted on
// the Slack webhook. Mattermost only wins if it's the sole configured channel.
var defaultTab = isTelegramBound ? "telegram"
: Model.HasMattermost && !Model.HasSlack ? "mattermost"
: "slack";
}


Expand All @@ -31,7 +40,7 @@
</h5>

<a href="javascript:removeChat();">
<i class='fas fa-trash-alt'></i> Remove
<i class='fas fa-trash-alt'></i> Remove chat
</a>
</div>

Expand Down Expand Up @@ -70,8 +79,10 @@
<label class="col-form-label" asp-for="EnableMessages"></label>
<i class='fas fa-question-circle ms-2' title='True means that alert notifications for this chat is enabled.'></i>
</div>
<div class="col-4 form-check form-switch mt-2">
<input id="messages-settings" class="form-check-input" type="checkbox" asp-for="EnableMessages">
<div class="col-6">
<div class="form-check form-switch mt-2">
<input id="messages-settings" class="form-check-input" type="checkbox" asp-for="EnableMessages">
</div>
</div>
</div>

Expand All @@ -80,71 +91,115 @@
<label class="col-form-label" asp-for="MessagesDelay"></label>
<i class='fas fa-question-circle ms-2' title='Alert notifications aggregation period in seconds.'></i>
</div>
<div class="col-4 p-0">
<div class="col-6">
<div class="input-group">
<input type="number" class="form-control" asp-for="MessagesDelay">
<span class="input-group-text">sec</span>
</div>
</div>
</div>

@if (isTelegramBound)
{
<div class="row mt-2">
<label class="col-2 col-form-label">Telegram</label>
<div class="col-6">
<span class="badge bg-secondary">
<i class="fab fa-telegram"></i>
@Model.TelegramType?.GetDisplayName()
</span>
<small class="text-muted d-block">ChatId: @Model.TelegramChatId</small>
<small class="text-muted d-block">Connected: @Model.AuthorizationTime?.ToLocalTime().ToString("g")</small>
</div>
</div>
}
else
{
@await Html.PartialAsync("_NewChatHelpModal.cshtml", Model.Id)
<div class="row mt-2">
<label class="col-2 col-form-label">Telegram</label>
<div class="col-6">
<a href="javascript:showAddChatHelpModal();" class="btn btn-link p-0">
<i class="fab fa-telegram"></i> Show setup help
</a>
</div>
</div>
}
<ul class="nav nav-tabs mt-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link @(defaultTab == "telegram" ? "active" : "")" id="telegram-tab"
data-bs-toggle="tab" data-bs-target="#telegram" type="button" role="tab"
aria-controls="telegram" aria-selected="@(defaultTab == "telegram" ? "true" : "false")">
<i class="fab fa-telegram"></i> Telegram
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(defaultTab == "slack" ? "active" : "")" id="slack-tab"
data-bs-toggle="tab" data-bs-target="#slack" type="button" role="tab"
aria-controls="slack" aria-selected="@(defaultTab == "slack" ? "true" : "false")">
<i class="fab fa-slack"></i> Slack
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link @(defaultTab == "mattermost" ? "active" : "")" id="mattermost-tab"
data-bs-toggle="tab" data-bs-target="#mattermost" type="button" role="tab"
aria-controls="mattermost" aria-selected="@(defaultTab == "mattermost" ? "true" : "false")">
<i class="fab fa-mattermost"></i> Mattermost
</button>
</li>
</ul>

<div class="row mt-2">
<div class="col-2 d-flex align-items-center">
<label class="col-form-label" asp-for="SlackWebhookUrl"></label>
</div>
<div class="col-6">
<input type="url" class="form-control" asp-for="SlackWebhookUrl" placeholder="https://hooks.slack.com/services/..." />
<div class="tab-content">
<div class="tab-pane fade @(defaultTab == "telegram" ? "show active" : "")" id="telegram"
role="tabpanel" aria-labelledby="telegram-tab">
@if (isTelegramBound)
{
<div class="row mt-2">
<label class="col-2 col-form-label">Telegram</label>
<div class="col-6">
<span class="badge bg-secondary">
<i class="fab fa-telegram"></i>
@Model.TelegramType?.GetDisplayName()
</span>
<small class="text-muted d-block">ChatId: @Model.TelegramChatId</small>
<small class="text-muted d-block">Connected: @Model.AuthorizationTime?.ToLocalTime().ToString("g")</small>
</div>
</div>
<div class="d-flex gap-2 mt-2">
<a id="sendTestTelegram" data-chat-id="@Model.TelegramChatId" class="btn btn-outline-secondary" style="cursor: pointer;">Send test Telegram message</a>
<a id="removeTelegram" class="btn btn-outline-danger" style="cursor: pointer;">Remove Telegram</a>
</div>
}
else
{
@await Html.PartialAsync("_NewChatHelpModal.cshtml", Model.Id)
<div class="row mt-2">
<label class="col-2 col-form-label">Telegram</label>
<div class="col-6">
<a href="javascript:showAddChatHelpModal();" class="btn btn-link p-0">
<i class="fab fa-telegram"></i> Show setup help
</a>
</div>
</div>
}
</div>
</div>

<div class="row mt-2">
<div class="col-2 d-flex align-items-center">
<label class="col-form-label" asp-for="MattermostWebhookUrl"></label>
<div class="tab-pane fade @(defaultTab == "slack" ? "show active" : "")" id="slack"
role="tabpanel" aria-labelledby="slack-tab">
<div class="row mt-2">
<div class="col-2 d-flex align-items-center">
<label class="col-form-label" asp-for="SlackWebhookUrl"></label>
</div>
<div class="col-6">
<input type="url" class="form-control" asp-for="SlackWebhookUrl" placeholder="https://hooks.slack.com/services/..." />
</div>
</div>
@if (Model.HasSlack)
{
<div class="d-flex gap-2 mt-2">
<a id="sendTestSlack" data-chat-id="@Model.Id" class="btn btn-outline-secondary" style="cursor: pointer;">Send test Slack message</a>
<a id="removeSlack" class="btn btn-outline-danger" style="cursor: pointer;">Remove Slack</a>
</div>
}
</div>
<div class="col-6">
<input type="url" class="form-control" asp-for="MattermostWebhookUrl" placeholder="https://your-mattermost/hooks/..." disabled />
<small class="text-muted">Mattermost delivery is not yet implemented.</small>

<div class="tab-pane fade @(defaultTab == "mattermost" ? "show active" : "")" id="mattermost"
role="tabpanel" aria-labelledby="mattermost-tab">
<div class="row mt-2">
<div class="col-2 d-flex align-items-center">
<label class="col-form-label" asp-for="MattermostWebhookUrl"></label>
</div>
<div class="col-6">
<input type="url" class="form-control" asp-for="MattermostWebhookUrl" placeholder="https://your-mattermost/hooks/..." disabled />
<small class="text-muted">Mattermost delivery is not yet implemented.</small>
</div>
</div>
@if (Model.HasMattermost)
{
<div class="d-flex gap-2 mt-2">
<a id="removeMattermost" class="btn btn-outline-danger" style="cursor: pointer;">Remove Mattermost</a>
</div>
}
</div>
</div>

<partial name="_ChatFolders" for="Folders" />

<div class="d-flex justify-content-end my-2 gap-2">
@if (isTelegramBound)
{
<a id="sendTestTelegram" data-chat-id="@Model.TelegramChatId" class="btn btn-outline-secondary" style="cursor: pointer;">Send test Telegram message</a>
}
@if (Model.HasSlack)
{
<a id="sendTestSlack" data-chat-id="@Model.Id" class="btn btn-outline-secondary" style="cursor: pointer;">Send test Slack message</a>
}
<div class="d-flex justify-content-end my-2">
<button type="submit" class="btn btn-primary independentSizeButton my-1">Save</button>
</div>
</form>
Expand Down Expand Up @@ -173,6 +228,25 @@
);
}

// Per-channel Remove buttons. Each clears only its channel; the Chat row and the other
// channels stay intact. Form reloads to reflect the post-clear state.
function removeChannel(channelName, actionUrl, confirmText) {
showConfirmationModal(
`Removing ${channelName} from chat '@Model.Name'`,
confirmText,
function () {
$.ajax({
type: 'POST',
url: actionUrl + `?id=@Model.Id`,
cache: false,
async: true,
success: function () { window.location.reload(); },
error: function () { showToast(`Failed to remove ${channelName.toLowerCase()} from chat.`); }
});
}
);
}

$(function () {
$("#sendTestTelegram").off("click").on("click", function () {
var chatId = $(this).data("chat-id");
Expand All @@ -187,5 +261,29 @@
.done(function () { showToast("Test Slack message sent."); })
.fail(function () { showToast("Failed to send test Slack message."); });
});

$("#removeTelegram").off("click").on("click", function () {
removeChannel(
"Telegram binding",
"@Url.Action(nameof(NotificationsController.RemoveTelegramBinding), ViewConstants.NotificationsController)",
`Telegram binding will be removed from chat '@Model.Name'. The chat itself, its folder bindings, and other channels stay intact.`
);
});

$("#removeSlack").off("click").on("click", function () {
removeChannel(
"Slack webhook",
"@Url.Action(nameof(NotificationsController.ClearSlackWebhook), ViewConstants.NotificationsController)",
`Slack webhook will be removed from chat '@Model.Name'. The chat itself and other channels stay intact.`
);
});

$("#removeMattermost").off("click").on("click", function () {
removeChannel(
"Mattermost webhook",
"@Url.Action(nameof(NotificationsController.ClearMattermostWebhook), ViewConstants.NotificationsController)",
`Mattermost webhook will be removed from chat '@Model.Name'. The chat itself and other channels stay intact.`
);
});
});
</script>
Loading
Loading