diff --git a/MythicReactUI/src/components/pages/Settings/SettingsOperatorUIConfigDialog.js b/MythicReactUI/src/components/pages/Settings/SettingsOperatorUIConfigDialog.js
index 41aa55da..b7ec971d 100644
--- a/MythicReactUI/src/components/pages/Settings/SettingsOperatorUIConfigDialog.js
+++ b/MythicReactUI/src/components/pages/Settings/SettingsOperatorUIConfigDialog.js
@@ -294,11 +294,22 @@ const COLOR_EDITOR_SECTIONS = [
],
},
{
- title: "Graphs and Floating Controls",
- description: "Graph grouping surfaces and legacy floating action controls.",
+ title: "Chat",
+ description: "Chat message bubbles and markdown block surfaces.",
+ colors: [
+ {name: "chatMessageOperatorBackground", display: "Other Messages", description: "Message bubble background for operator messages from other users.", preview: "chat"},
+ {name: "chatMessageSelfBackground", display: "My Messages", description: "Message bubble background for messages sent by the current operator.", preview: "chat"},
+ {name: "chatMessageAIBackground", display: "AI Messages", description: "Message bubble background for AI-generated messages.", preview: "chat"},
+ {name: "chatMessageSystemBackground", display: "System Messages", description: "Message bubble background for chat system messages.", preview: "chat"},
+ {name: "chatMarkdownSurfaceBackground", display: "Markdown Surface", description: "Background for inline code, fenced code blocks, blockquotes, and markdown table headers.", preview: "chat"},
+ {name: "chatMarkdownSurfaceStrongBackground", display: "Markdown Label Surface", description: "Background for compact markdown labels such as fenced-code language tags.", preview: "chat"},
+ ],
+ },
+ {
+ title: "Graphs",
+ description: "Graph grouping surfaces.",
colors: [
{name: "graphGroupColor", display: "Graph Group", description: "Grouped node backgrounds in graph-style views.", preview: "graph"},
- {name: "speedDialAction", display: "Floating Action", description: "Floating action buttons where SpeedDial controls are still used.", preview: "floatingAction"},
],
},
{
@@ -517,7 +528,6 @@ const ColorUsagePreview = ({option, palette, mode}) => {
const subtleAccentGradientStart = getPaletteValue(palette, "subtleAccentGradientStart", mode);
const subtleAccentGradientEnd = getPaletteValue(palette, "subtleAccentGradientEnd", mode);
const graphGroup = getPaletteValue(palette, "graphGroupColor", mode);
- const speedDialAction = getPaletteValue(palette, "speedDialAction", mode);
const chartSeriesColors = Array.from({length: 10}, (_, index) => getPaletteValue(palette, `chartSeries${index + 1}`, mode));
const navTop = getPaletteValue(palette, "navBarColor", mode);
const navBottom = getPaletteValue(palette, "navBarBottomColor", mode);
@@ -536,6 +546,12 @@ const ColorUsagePreview = ({option, palette, mode}) => {
const extra = getPaletteValue(palette, "taskContextExtraColor", mode);
const folder = getPaletteValue(palette, "folderColor", mode);
const emptyFolder = getPaletteValue(palette, "emptyFolderColor", mode);
+ const chatOperatorBackground = getPaletteValue(palette, "chatMessageOperatorBackground", mode);
+ const chatSelfBackground = getPaletteValue(palette, "chatMessageSelfBackground", mode);
+ const chatAIBackground = getPaletteValue(palette, "chatMessageAIBackground", mode);
+ const chatSystemBackground = getPaletteValue(palette, "chatMessageSystemBackground", mode);
+ const chatMarkdownSurface = getPaletteValue(palette, "chatMarkdownSurfaceBackground", mode);
+ const chatMarkdownSurfaceStrong = getPaletteValue(palette, "chatMarkdownSurfaceStrongBackground", mode);
const previewColor = getPaletteValue(palette, option.name, mode);
const shellSx = {
border: `1px solid ${addAlpha(border, "99")}`,
@@ -637,12 +653,6 @@ const ColorUsagePreview = ({option, palette, mode}) => {
Grouped graph node
);
- case "floatingAction":
- return (
-
-
-
- );
case "chart":
return (
@@ -661,6 +671,33 @@ const ColorUsagePreview = ({option, palette, mode}) => {
);
+ case "chat":
+ return (
+
+
+
+ Other operator
+
+
+ My message
+
+
+ AI response
+
+
+ markdown block
+
+
+
+
+
+ System
+
+
+
+
+
+ );
case "task":
return (
diff --git a/MythicReactUI/src/themes/GlobalStyles.js b/MythicReactUI/src/themes/GlobalStyles.js
index ce3e42ce..8fc5f6e8 100644
--- a/MythicReactUI/src/themes/GlobalStyles.js
+++ b/MythicReactUI/src/themes/GlobalStyles.js
@@ -13,6 +13,7 @@ const getSubtleAccentGradient = (props) => props.theme.gradients?.subtleAccent |
`linear-gradient(135deg, ${alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.13 : 0.075)} 0%, ${alpha(props.theme.palette.background.paper, 0)} 62%)`;
const getSubtleAccentHorizontalGradient = (props) => props.theme.gradients?.subtleAccentHorizontal ||
`linear-gradient(90deg, ${alpha(props.theme.palette.primary.main, props.theme.palette.mode === "dark" ? 0.12 : 0.07)} 0%, ${alpha(props.theme.palette.background.paper, 0)} 100%)`;
+const getSoftBorderColor = (props) => props.theme.table?.borderSoft || props.theme.borderColor;
export const GlobalStyles = createGlobalStyle`
body {
@@ -131,13 +132,6 @@ tspan {
min-height: unset;
max-width: unset;
}
-.MuiSpeedDialAction-staticTooltipLabel {
- white-space: nowrap;
- max-width: none;
-}
-.MuiSpeedDialAction-fab {
- background-color: ${(props) => props.theme.palette.speedDialAction};
-}
.MuiTooltip-tooltip {
background-color: ${(props) => props.theme.palette.background.contrast};
color: ${(props) => props.theme.palette.text.contrast};
@@ -4655,6 +4649,7 @@ tspan {
line-height: 1.4;
margin-top: 0.25rem;
overflow-wrap: anywhere;
+ white-space: pre-wrap;
}
.mythic-metadata-grid {
display: grid;
@@ -9985,7 +9980,7 @@ tspan {
line-height: 1.35;
margin: 0;
overflow: hidden;
- white-space: normal;
+ white-space: pre-wrap;
}
.mythic-installed-service-browser-metadata {
display: flex;
@@ -10111,6 +10106,7 @@ tspan {
font-weight: 650;
line-height: 1.35;
overflow-wrap: anywhere;
+ white-space: pre-wrap;
}
.mythic-installed-service-definition-action {
align-items: center;
@@ -10479,6 +10475,488 @@ tspan {
color: inherit;
font-size: 1rem;
}
+.mythic-chat-layout {
+ display: grid;
+ grid-template-columns: minmax(240px, 300px) minmax(0, 1fr);
+ gap: 8px;
+ min-height: 0;
+ flex: 1 1 auto;
+ border-radius: 8px;
+ overflow: visible;
+}
+.mythic-chat-sidebar {
+ background-color: ${(props) => alpha(props.theme.palette.background.paper, props.theme.palette.mode === "dark" ? 0.72 : 0.86)};
+ border: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.18)};
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ border-radius: 8px;
+ box-shadow: inset 0 1px 0 ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.12)};
+ overflow: hidden;
+}
+.mythic-chat-sidebar-toolbar {
+ background-color: ${(props) => props.theme.pageHeader?.main || props.theme.palette.background.paper};
+ border-bottom: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.18)};
+ color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary};
+ min-height: 48px;
+}
+.mythic-chat-sidebar-toolbar,
+.mythic-chat-conversation-header,
+.mythic-chat-composer {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ padding: 8px 10px;
+}
+.mythic-chat-sidebar-heading {
+ align-items: center;
+ display: flex;
+ gap: 8px;
+ min-width: 0;
+}
+.mythic-chat-sidebar-heading .MuiTypography-root {
+ font-weight: 750;
+}
+.mythic-chat-sidebar-count {
+ background-color: ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.1)} !important;
+ border: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.24)} !important;
+ color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary} !important;
+ font-size: 0.68rem !important;
+ font-weight: 800 !important;
+ height: 22px !important;
+}
+.mythic-chat-conversation-header {
+ background-color: ${(props) => props.theme.pageHeader?.main || props.theme.palette.background.paper};
+ border-bottom: 1px solid ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.18)};
+ box-shadow: inset 0 1px 0 ${(props) => alpha(props.theme.pageHeaderText?.main || props.theme.palette.text.primary, 0.12)};
+ color: ${(props) => props.theme.pageHeaderText?.main || props.theme.palette.text.primary};
+ min-height: 54px;
+}
+.mythic-chat-composer {
+ box-shadow: inset 0 1px 0 ${getSoftBorderColor};
+ align-items: center;
+}
+.mythic-chat-channel-section {
+ display: flex;
+ flex-direction: column;
+ gap: 5px;
+ padding: 8px;
+ overflow-y: auto;
+}
+.mythic-chat-channel-section > .MuiTypography-caption {
+ font-size: 0.68rem;
+ font-weight: 800;
+ letter-spacing: 0;
+ text-transform: uppercase;
+}
+.mythic-chat-channel-button {
+ align-items: center;
+ border: 1px solid transparent;
+ border-radius: 7px;
+ cursor: pointer;
+ display: grid;
+ gap: 8px;
+ grid-template-columns: 26px minmax(0, 1fr) auto;
+ min-height: 42px;
+ padding: 6px 8px;
+ text-align: left;
+ transition: background-color 120ms ease, border-color 120ms ease;
+}
+.mythic-chat-channel-button:hover {
+ background-color: ${(props) => props.theme.surfaces?.hover || props.theme.palette.action.hover} !important;
+ border-color: ${getSoftBorderColor} !important;
+}
+.mythic-chat-channel-button-selected {
+ box-shadow: inset 3px 0 0 var(--mythic-chat-channel-accent);
+}
+.mythic-chat-channel-icon,
+.mythic-chat-author,
+.mythic-chat-search-channel {
+ align-items: center;
+ display: inline-flex;
+ gap: 5px;
+}
+.mythic-chat-channel-icon {
+ color: var(--mythic-chat-channel-accent);
+ justify-content: center;
+}
+.mythic-chat-channel-button-archived .mythic-chat-channel-icon {
+ color: var(--mythic-chat-channel-warning);
+}
+.mythic-chat-channel-button-archived .mythic-chat-channel-name,
+.mythic-chat-channel-button-archived .mythic-chat-channel-meta {
+ color: var(--mythic-chat-channel-muted);
+}
+.mythic-chat-channel-main {
+ display: flex;
+ flex-direction: column;
+ gap: 3px;
+ min-width: 0;
+}
+.mythic-chat-channel-name {
+ font-weight: 650;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mythic-chat-channel-meta {
+ font-size: 0.74rem;
+ opacity: 0.72;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mythic-chat-channel-states {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+}
+.mythic-chat-channel-state,
+.mythic-chat-unread-badge {
+ align-items: center;
+ background: ${(props) => props.theme.palette.action.selected};
+ border-radius: 999px;
+ display: inline-flex;
+ font-size: 0.66rem;
+ font-weight: 800;
+ line-height: 1.2;
+ padding: 2px 6px;
+ white-space: nowrap;
+}
+.mythic-chat-channel-state-archived {
+ color: var(--mythic-chat-channel-warning);
+ box-shadow: inset 0 0 0 1px var(--mythic-chat-channel-warning);
+}
+.mythic-chat-channel-state-locked {
+ color: var(--mythic-chat-channel-warning);
+}
+.mythic-chat-channel-state-offline {
+ color: var(--mythic-chat-channel-error);
+}
+.mythic-chat-unread-badge {
+ align-self: center;
+ color: var(--mythic-chat-channel-error);
+ box-shadow: inset 0 0 0 1px var(--mythic-chat-channel-error);
+}
+.mythic-chat-main {
+ background-color: ${(props) => props.theme.palette.background.paper};
+ border: 1px solid ${(props) => props.theme.table?.borderSoft || props.theme.borderColor};
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+ min-width: 0;
+ border-radius: 8px;
+ overflow: hidden;
+}
+.mythic-chat-conversation-icon {
+ align-items: center;
+ border: 1px solid;
+ border-radius: 7px;
+ display: inline-flex;
+ flex: 0 0 auto;
+ height: 32px;
+ justify-content: center;
+ width: 32px;
+}
+.mythic-chat-conversation-title {
+ font-weight: 800 !important;
+ line-height: 1.2 !important;
+}
+.mythic-chat-conversation-subtitle {
+ opacity: 0.82;
+}
+.mythic-chat-header-actions {
+ align-items: center;
+ display: flex;
+ gap: 5px;
+}
+.mythic-chat-messages {
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 8px;
+ min-height: 0;
+ overflow-y: auto;
+ padding: 10px;
+}
+.mythic-chat-message-row {
+ display: flex;
+ justify-content: flex-start;
+}
+.mythic-chat-message-row-mine {
+ justify-content: flex-end;
+}
+.mythic-chat-message {
+ border: 1px solid var(--mythic-chat-markdown-border);
+ border-radius: 8px;
+ max-width: 100%;
+ min-width: 0;
+ overflow: hidden;
+ padding: 0;
+ width: fit-content;
+}
+.mythic-chat-message-header {
+ align-items: center;
+ background: var(--mythic-chat-markdown-surface);
+ border-bottom: 1px solid var(--mythic-chat-markdown-border);
+ display: flex;
+ gap: 8px;
+ justify-content: space-between;
+ margin: 0;
+ padding: 7px 10px;
+}
+.mythic-chat-message-system .mythic-chat-message-header {
+ background: ${(props) => `linear-gradient(135deg, ${alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.34 : 0.24)} 0%, ${alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.18 : 0.12)} 58%, ${alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.08 : 0.06)} 100%)`};
+ border-bottom-color: ${(props) => alpha(props.theme.palette.warning.main, props.theme.palette.mode === "dark" ? 0.36 : 0.28)};
+}
+.mythic-chat-author {
+ font-weight: 750;
+ min-width: 0;
+}
+.mythic-chat-author > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mythic-chat-message-actions {
+ align-items: center;
+ display: flex;
+ gap: 2px;
+ white-space: nowrap;
+
+}
+.mythic-chat-message-actions .MuiTypography-caption {
+ font-size: 0.72rem;
+ font-weight: 650;
+}
+.mythic-chat-message-actions .MuiIconButton-root,
+.mythic-chat-header-actions .MuiIconButton-root {
+ border-radius: 6px;
+ height: 28px;
+ width: 28px;
+}
+.mythic-chat-paragraph {
+ margin: 0 0 6px 0;
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+.mythic-chat-paragraph:last-child {
+ margin-bottom: 0;
+}
+.mythic-chat-markdown {
+ overflow-wrap: anywhere;
+ padding: 8px 10px 9px;
+}
+.mythic-chat-edit-box {
+ padding: 8px 10px 9px;
+}
+.mythic-chat-message > .MuiTypography-caption {
+ padding: 0 10px 8px;
+}
+.mythic-chat-heading {
+ font-weight: 800;
+ line-height: 1.25;
+ margin: 0 0 6px 0;
+}
+.mythic-chat-heading-1,
+.mythic-chat-heading-2 {
+ font-size: 1rem;
+}
+.mythic-chat-heading-3,
+.mythic-chat-heading-4,
+.mythic-chat-heading-5,
+.mythic-chat-heading-6 {
+ font-size: 0.9rem;
+}
+.mythic-chat-list {
+ margin: 4px 0 8px 0;
+ padding-left: 1.25rem;
+}
+.mythic-chat-list li {
+ margin: 2px 0;
+ padding-left: 0.15rem;
+}
+.mythic-chat-blockquote {
+ background: var(--mythic-chat-markdown-surface);
+ border: 1px solid var(--mythic-chat-markdown-border);
+ border-left: 3px solid var(--mythic-chat-markdown-border);
+ border-radius: 7px;
+ color: inherit;
+ margin: 6px 0;
+ padding: 4px 8px 4px 10px;
+}
+.mythic-chat-rule {
+ border: 0;
+ border-top: 1px solid var(--mythic-chat-markdown-border);
+ margin: 8px 0;
+}
+.mythic-chat-inline-code {
+ background: var(--mythic-chat-markdown-surface);
+ border: 1px solid var(--mythic-chat-markdown-border);
+ border-radius: 4px;
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.88em;
+ padding: 1px 4px;
+}
+.mythic-chat-code-block {
+ margin: 6px 0;
+ position: relative;
+}
+.mythic-chat-code-block pre {
+ background: var(--mythic-chat-markdown-surface);
+ border: 1px solid var(--mythic-chat-markdown-border);
+ border-radius: 7px;
+ margin: 0;
+ overflow-x: auto;
+ padding: 10px;
+}
+.mythic-chat-code-block code {
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ font-size: 0.86rem;
+}
+.mythic-chat-code-language {
+ background: var(--mythic-chat-markdown-surface-strong);
+ border: 1px solid var(--mythic-chat-markdown-border);
+ border-radius: 4px;
+ font-size: 0.68rem;
+ font-weight: 700;
+ padding: 1px 5px;
+ position: absolute;
+ right: 6px;
+ top: 5px;
+}
+.mythic-chat-table-wrap {
+ margin: 6px 0;
+ max-width: 100%;
+ overflow-x: auto;
+ width: fit-content;
+}
+.mythic-chat-table {
+ font-size: 0.82rem;
+ min-width: 100%;
+ width: 100%;
+}
+.mythic-chat-table .MuiTableCell-root {
+ font-size: 0.82rem;
+ vertical-align: top;
+}
+.mythic-chat-edit-box {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+.mythic-chat-edit-actions {
+ display: flex;
+ gap: 6px;
+ justify-content: flex-end;
+}
+.mythic-chat-dialog-content {
+ padding-top: 20px !important;
+}
+.mythic-chat-composer .MuiFormControl-root {
+ flex: 1 1 auto;
+}
+.mythic-chat-composer .MuiOutlinedInput-root {
+ align-items: center;
+ background: transparent;
+ padding-left: 0;
+}
+.mythic-chat-composer .MuiOutlinedInput-notchedOutline {
+ border: 0 !important;
+}
+.mythic-chat-composer .MuiInputBase-input {
+ padding-left: 0 !important;
+}
+.mythic-chat-send-button .MuiSvgIcon-root {
+ display: block;
+}
+.mythic-chat-system-button.MuiIconButton-root {
+ border-radius: ${(props) => props.theme.shape.borderRadius}px;
+ flex: 0 0 auto;
+ height: 38px;
+ width: 38px;
+}
+.mythic-chat-system-destination {
+ align-items: center;
+ background-color: ${(props) => props.theme.surfaces?.hover || props.theme.palette.action.hover};
+ border: 1px solid ${getSoftBorderColor};
+ border-radius: 7px;
+ display: flex;
+ gap: 8px;
+ min-width: 0;
+ padding: 8px 10px;
+}
+.mythic-chat-system-destination .MuiSvgIcon-root {
+ color: ${(props) => props.theme.palette.warning.main};
+}
+.mythic-chat-system-options {
+ background-color: ${(props) => props.theme.surfaces?.hover || props.theme.palette.action.hover};
+ border: 1px solid ${getSoftBorderColor};
+ border-radius: 7px;
+ padding: 4px 10px;
+}
+.mythic-chat-dialog-content .MuiFormControl-root {
+ margin-top: 4px;
+}
+.mythic-chat-empty-state {
+ align-items: center;
+ align-self: center;
+ display: flex;
+ flex: 1 1 auto;
+ flex-direction: column;
+ gap: 6px;
+ justify-content: center;
+ min-height: 12rem;
+ opacity: 0.72;
+ text-align: center;
+}
+.mythic-chat-empty-list {
+ background: ${(props) => props.theme.palette.action.hover};
+ border-radius: 7px;
+ font-size: 0.76rem;
+ opacity: 0.72;
+ padding: 9px;
+}
+.mythic-chat-search-results {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ max-height: 55vh;
+ overflow-y: auto;
+}
+.mythic-chat-search-result {
+ background: transparent;
+ border: 1px solid ${getSoftBorderColor};
+ border-radius: 7px;
+ color: inherit;
+ cursor: pointer;
+ display: grid;
+ gap: 3px;
+ padding: 8px;
+ text-align: left;
+}
+.mythic-chat-search-message {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.mythic-chat-search-meta {
+ font-size: 0.74rem;
+ opacity: 0.72;
+}
+@media (max-width: 900px) {
+ .mythic-chat-layout {
+ grid-template-columns: 1fr;
+ }
+ .mythic-chat-sidebar {
+ max-height: 220px;
+ }
+ .mythic-chat-message {
+ max-width: 100%;
+ min-width: 0;
+ }
+}
.ace_editor{
background-color: ${(props) => props.theme.outputBackgroundColor + "20"};
}
diff --git a/hasura-docker/metadata/actions.graphql b/hasura-docker/metadata/actions.graphql
index 726b23df..39d1945e 100644
--- a/hasura-docker/metadata/actions.graphql
+++ b/hasura-docker/metadata/actions.graphql
@@ -51,6 +51,80 @@ type Query {
): ConfigCheckOutput
}
+type Mutation {
+ chatCancelRequest(
+ request_id: Int!
+ ): ChatActionOutput
+}
+
+type Mutation {
+ chatCreateChannel(
+ name: String!
+ description: String
+ channel_type: String
+ chat_container_id: Int
+ chat_model: String
+ locked: Boolean
+ ai_metadata: jsonb
+ ): ChatActionOutput
+}
+
+type Mutation {
+ chatCreateMessage(
+ channel_id: Int!
+ message: String!
+ system_message: Boolean
+ all_operations: Boolean
+ ): ChatActionOutput
+}
+
+type Mutation {
+ chatDeleteMessage(
+ message_id: Int!
+ ): ChatActionOutput
+}
+
+type Mutation {
+ chatEditMessage(
+ message_id: Int!
+ message: String!
+ ): ChatActionOutput
+}
+
+type Mutation {
+ chatMarkRead(
+ channel_id: Int!
+ last_read_message_id: Int
+ ): ChatActionOutput
+}
+
+type Mutation {
+ chatRetryRequest(
+ request_id: Int!
+ ): ChatActionOutput
+}
+
+type Query {
+ chatSearch(
+ query: String!
+ channel_id: Int
+ limit: Int
+ offset: Int
+ ): ChatActionOutput
+}
+
+type Mutation {
+ chatUpdateChannel(
+ channel_id: Int!
+ name: String
+ description: String
+ archived: Boolean
+ locked: Boolean
+ chat_model: String
+ ai_metadata: jsonb
+ ): ChatActionOutput
+}
+
type Mutation {
consumingServicesTestLog(
service_type: String!
@@ -1327,3 +1401,28 @@ type custombrowserExportFunctionOutput {
error: String
}
+type ChatActionOutput {
+ status: String!
+ error: String
+ id: Int
+ channel_id: Int
+ message_id: Int
+ request_id: Int
+ response_message_id: Int
+ results: [ChatSearchResult]
+}
+
+type ChatSearchResult {
+ id: Int!
+ channel_id: Int!
+ channel_name: String!
+ channel_slug: String!
+ channel_type: String!
+ author_type: String!
+ sender_display_name: String!
+ message: String!
+ edited: Boolean!
+ status: String!
+ created_at: String!
+ rank: Float!
+}
diff --git a/hasura-docker/metadata/actions.yaml b/hasura-docker/metadata/actions.yaml
index 28c44535..299085ae 100644
--- a/hasura-docker/metadata/actions.yaml
+++ b/hasura-docker/metadata/actions.yaml
@@ -91,6 +91,107 @@ actions:
- role: mythic_admin
- role: developer
comment: Using the UUID of a payload, ask's that payload's C2 Profiles to check if the Payload's configuration matches with the configuration for the profile itself.
+ - name: chatCancelRequest
+ definition:
+ kind: synchronous
+ handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_cancel_request_webhook'
+ forward_client_headers: true
+ permissions:
+ - role: operator
+ - role: operation_admin
+ - role: mythic_admin
+ - role: developer
+ comment: Cancel an in-flight AI chat request
+ - name: chatCreateChannel
+ definition:
+ kind: synchronous
+ handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_create_channel_webhook'
+ forward_client_headers: true
+ permissions:
+ - role: operator
+ - role: operation_admin
+ - role: mythic_admin
+ - role: developer
+ comment: Create a standard or AI chat channel in the current operation
+ - name: chatCreateMessage
+ definition:
+ kind: synchronous
+ handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_create_message_webhook'
+ forward_client_headers: true
+ permissions:
+ - role: operator
+ - role: operation_admin
+ - role: mythic_admin
+ - role: developer
+ comment: Create a chat message, submit an AI chat prompt, or submit an admin-only system message across one or all operations
+ - name: chatDeleteMessage
+ definition:
+ kind: synchronous
+ handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_delete_message_webhook'
+ forward_client_headers: true
+ permissions:
+ - role: operator
+ - role: operation_admin
+ - role: mythic_admin
+ - role: developer
+ comment: Delete a chat message visible in the current operation
+ - name: chatEditMessage
+ definition:
+ kind: synchronous
+ handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_edit_message_webhook'
+ forward_client_headers: true
+ permissions:
+ - role: operator
+ - role: operation_admin
+ - role: mythic_admin
+ - role: developer
+ comment: Edit an operator-authored chat message
+ - name: chatMarkRead
+ definition:
+ kind: synchronous
+ handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_mark_read_webhook'
+ forward_client_headers: true
+ permissions:
+ - role: spectator
+ - role: operator
+ - role: operation_admin
+ - role: mythic_admin
+ - role: developer
+ comment: Mark a chat channel as read for the current operator
+ - name: chatRetryRequest
+ definition:
+ kind: synchronous
+ handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_retry_request_webhook'
+ forward_client_headers: true
+ permissions:
+ - role: operator
+ - role: operation_admin
+ - role: mythic_admin
+ - role: developer
+ comment: Retry an AI chat request in the same AI channel
+ - name: chatSearch
+ definition:
+ kind: ""
+ handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_search_webhook'
+ forward_client_headers: true
+ permissions:
+ - role: spectator
+ - role: operator
+ - role: operation_admin
+ - role: mythic_admin
+ - role: developer
+ comment: Search operation chat messages visible to the current token
+ - name: chatUpdateChannel
+ definition:
+ kind: synchronous
+ handler: '{{MYTHIC_ACTIONS_URL_BASE}}/chat_update_channel_webhook'
+ forward_client_headers: true
+ permissions:
+ - role: operator
+ - role: operation_admin
+ - role: mythic_admin
+ - role: developer
+ comment: Update, archive, or lock a chat channel
- name: consumingServicesTestLog
definition:
kind: synchronous
diff --git a/hasura-docker/metadata/databases/default/tables/public_chat_channel.yaml b/hasura-docker/metadata/databases/default/tables/public_chat_channel.yaml
new file mode 100644
index 00000000..641a0b5e
--- /dev/null
+++ b/hasura-docker/metadata/databases/default/tables/public_chat_channel.yaml
@@ -0,0 +1,194 @@
+table:
+ name: chat_channel
+ schema: public
+object_relationships:
+ - name: archived_operator
+ using:
+ foreign_key_constraint_on: archived_by
+ - name: chat_container
+ using:
+ foreign_key_constraint_on: chat_container_id
+ - name: creator
+ using:
+ foreign_key_constraint_on: created_by
+ - name: locked_operator
+ using:
+ foreign_key_constraint_on: locked_by
+ - name: operation
+ using:
+ foreign_key_constraint_on: operation_id
+array_relationships:
+ - name: messages
+ using:
+ foreign_key_constraint_on:
+ column: channel_id
+ table:
+ name: chat_message
+ schema: public
+ - name: read_states
+ using:
+ foreign_key_constraint_on:
+ column: channel_id
+ table:
+ name: chat_read_state
+ schema: public
+ - name: requests
+ using:
+ foreign_key_constraint_on:
+ column: channel_id
+ table:
+ name: chat_request
+ schema: public
+select_permissions:
+ - role: mythic_admin
+ permission:
+ columns:
+ - ai_metadata
+ - archived
+ - archived_at
+ - archived_by
+ - channel_type
+ - chat_container_id
+ - chat_model
+ - created_at
+ - created_by
+ - description
+ - id
+ - locked
+ - locked_at
+ - locked_by
+ - last_message_id
+ - name
+ - operation_id
+ - slug
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: operation_admin
+ permission:
+ columns:
+ - ai_metadata
+ - archived
+ - archived_at
+ - archived_by
+ - channel_type
+ - chat_container_id
+ - chat_model
+ - created_at
+ - created_by
+ - description
+ - id
+ - locked
+ - locked_at
+ - locked_by
+ - last_message_id
+ - name
+ - operation_id
+ - slug
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: operator
+ permission:
+ columns:
+ - ai_metadata
+ - archived
+ - archived_at
+ - archived_by
+ - channel_type
+ - chat_container_id
+ - chat_model
+ - created_at
+ - created_by
+ - description
+ - id
+ - locked
+ - locked_at
+ - locked_by
+ - last_message_id
+ - name
+ - operation_id
+ - slug
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: spectator
+ permission:
+ columns:
+ - ai_metadata
+ - archived
+ - archived_at
+ - archived_by
+ - channel_type
+ - chat_container_id
+ - chat_model
+ - created_at
+ - created_by
+ - description
+ - id
+ - locked
+ - locked_at
+ - locked_by
+ - last_message_id
+ - name
+ - operation_id
+ - slug
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
diff --git a/hasura-docker/metadata/databases/default/tables/public_chat_message.yaml b/hasura-docker/metadata/databases/default/tables/public_chat_message.yaml
new file mode 100644
index 00000000..748ffd43
--- /dev/null
+++ b/hasura-docker/metadata/databases/default/tables/public_chat_message.yaml
@@ -0,0 +1,197 @@
+table:
+ name: chat_message
+ schema: public
+object_relationships:
+ - name: apitoken
+ using:
+ foreign_key_constraint_on: apitokens_id
+ - name: channel
+ using:
+ foreign_key_constraint_on: channel_id
+ - name: chat_container
+ using:
+ foreign_key_constraint_on: chat_container_id
+ - name: deleted_operator
+ using:
+ foreign_key_constraint_on: deleted_by
+ - name: operation
+ using:
+ foreign_key_constraint_on: operation_id
+ - name: operator
+ using:
+ foreign_key_constraint_on: operator_id
+array_relationships:
+ - name: requests_as_prompt
+ using:
+ foreign_key_constraint_on:
+ column: request_message_id
+ table:
+ name: chat_request
+ schema: public
+ - name: requests_as_response
+ using:
+ foreign_key_constraint_on:
+ column: response_message_id
+ table:
+ name: chat_request
+ schema: public
+ - name: read_states
+ using:
+ foreign_key_constraint_on:
+ column: last_read_message_id
+ table:
+ name: chat_read_state
+ schema: public
+select_permissions:
+ - role: mythic_admin
+ permission:
+ columns:
+ - apitokens_id
+ - author_type
+ - channel_id
+ - chat_container_id
+ - created_at
+ - deleted
+ - deleted_at
+ - deleted_by
+ - edited
+ - edited_at
+ - id
+ - message
+ - metadata
+ - operation_id
+ - operator_id
+ - sender_display_name
+ - status
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - channel:
+ _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: operation_admin
+ permission:
+ columns:
+ - apitokens_id
+ - author_type
+ - channel_id
+ - chat_container_id
+ - created_at
+ - deleted
+ - deleted_at
+ - deleted_by
+ - edited
+ - edited_at
+ - id
+ - message
+ - metadata
+ - operation_id
+ - operator_id
+ - sender_display_name
+ - status
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - channel:
+ _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: operator
+ permission:
+ columns:
+ - apitokens_id
+ - author_type
+ - channel_id
+ - chat_container_id
+ - created_at
+ - deleted
+ - deleted_at
+ - deleted_by
+ - edited
+ - edited_at
+ - id
+ - message
+ - metadata
+ - operation_id
+ - operator_id
+ - sender_display_name
+ - status
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - channel:
+ _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: spectator
+ permission:
+ columns:
+ - apitokens_id
+ - author_type
+ - channel_id
+ - chat_container_id
+ - created_at
+ - deleted
+ - deleted_at
+ - deleted_by
+ - edited
+ - edited_at
+ - id
+ - message
+ - metadata
+ - operation_id
+ - operator_id
+ - sender_display_name
+ - status
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - channel:
+ _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
diff --git a/hasura-docker/metadata/databases/default/tables/public_chat_read_state.yaml b/hasura-docker/metadata/databases/default/tables/public_chat_read_state.yaml
new file mode 100644
index 00000000..f79567ca
--- /dev/null
+++ b/hasura-docker/metadata/databases/default/tables/public_chat_read_state.yaml
@@ -0,0 +1,125 @@
+table:
+ name: chat_read_state
+ schema: public
+object_relationships:
+ - name: channel
+ using:
+ foreign_key_constraint_on: channel_id
+ - name: last_read_message
+ using:
+ foreign_key_constraint_on: last_read_message_id
+ - name: operation
+ using:
+ foreign_key_constraint_on: operation_id
+ - name: operator
+ using:
+ foreign_key_constraint_on: operator_id
+select_permissions:
+ - role: mythic_admin
+ permission:
+ columns:
+ - channel_id
+ - last_read_message_id
+ - operation_id
+ - operator_id
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - operator_id:
+ _eq: X-Hasura-User-Id
+ - channel:
+ _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: operation_admin
+ permission:
+ columns:
+ - channel_id
+ - last_read_message_id
+ - operation_id
+ - operator_id
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - operator_id:
+ _eq: X-Hasura-User-Id
+ - channel:
+ _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: operator
+ permission:
+ columns:
+ - channel_id
+ - last_read_message_id
+ - operation_id
+ - operator_id
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - operator_id:
+ _eq: X-Hasura-User-Id
+ - channel:
+ _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: spectator
+ permission:
+ columns:
+ - channel_id
+ - last_read_message_id
+ - operation_id
+ - operator_id
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - operator_id:
+ _eq: X-Hasura-User-Id
+ - channel:
+ _or:
+ - _and:
+ - channel_type:
+ _eq: standard
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-read-operation
+ - _and:
+ - channel_type:
+ _eq: ai
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
diff --git a/hasura-docker/metadata/databases/default/tables/public_chat_request.yaml b/hasura-docker/metadata/databases/default/tables/public_chat_request.yaml
new file mode 100644
index 00000000..dab0de05
--- /dev/null
+++ b/hasura-docker/metadata/databases/default/tables/public_chat_request.yaml
@@ -0,0 +1,130 @@
+table:
+ name: chat_request
+ schema: public
+object_relationships:
+ - name: channel
+ using:
+ foreign_key_constraint_on: channel_id
+ - name: chat_container
+ using:
+ foreign_key_constraint_on: chat_container_id
+ - name: creator
+ using:
+ foreign_key_constraint_on: created_by
+ - name: operation
+ using:
+ foreign_key_constraint_on: operation_id
+ - name: request_message
+ using:
+ foreign_key_constraint_on: request_message_id
+ - name: response_message
+ using:
+ foreign_key_constraint_on: response_message_id
+ - name: retry_of
+ using:
+ foreign_key_constraint_on: retry_of_id
+select_permissions:
+ - role: mythic_admin
+ permission:
+ columns:
+ - cancelled_at
+ - channel_id
+ - chat_container_id
+ - completed_at
+ - context_snapshot
+ - created_at
+ - created_by
+ - error
+ - id
+ - model
+ - operation_id
+ - request_message_id
+ - response_message_id
+ - retry_of_id
+ - status
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: operation_admin
+ permission:
+ columns:
+ - cancelled_at
+ - channel_id
+ - chat_container_id
+ - completed_at
+ - context_snapshot
+ - created_at
+ - created_by
+ - error
+ - id
+ - model
+ - operation_id
+ - request_message_id
+ - response_message_id
+ - retry_of_id
+ - status
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: operator
+ permission:
+ columns:
+ - cancelled_at
+ - channel_id
+ - chat_container_id
+ - completed_at
+ - context_snapshot
+ - created_at
+ - created_by
+ - error
+ - id
+ - model
+ - operation_id
+ - request_message_id
+ - response_message_id
+ - retry_of_id
+ - status
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
+ - role: spectator
+ permission:
+ columns:
+ - cancelled_at
+ - channel_id
+ - chat_container_id
+ - completed_at
+ - context_snapshot
+ - created_at
+ - created_by
+ - error
+ - id
+ - model
+ - operation_id
+ - request_message_id
+ - response_message_id
+ - retry_of_id
+ - status
+ - updated_at
+ filter:
+ _and:
+ - operation_id:
+ _eq: X-Hasura-current-operation-id
+ - operation_id:
+ _lte: X-Hasura-Scope-chat-ai-read-operation
+ allow_aggregations: true
diff --git a/hasura-docker/metadata/databases/default/tables/tables.yaml b/hasura-docker/metadata/databases/default/tables/tables.yaml
index 894525fd..019126e5 100644
--- a/hasura-docker/metadata/databases/default/tables/tables.yaml
+++ b/hasura-docker/metadata/databases/default/tables/tables.yaml
@@ -14,6 +14,10 @@
- "!include public_callbackgraphedge.yaml"
- "!include public_callbackport.yaml"
- "!include public_callbacktoken.yaml"
+- "!include public_chat_channel.yaml"
+- "!include public_chat_message.yaml"
+- "!include public_chat_read_state.yaml"
+- "!include public_chat_request.yaml"
- "!include public_command.yaml"
- "!include public_commandparameters.yaml"
- "!include public_consuming_container.yaml"
diff --git a/mythic-docker/src/authentication/mythicjwt/scopes.go b/mythic-docker/src/authentication/mythicjwt/scopes.go
index 339ca05b..7aad1c63 100644
--- a/mythic-docker/src/authentication/mythicjwt/scopes.go
+++ b/mythic-docker/src/authentication/mythicjwt/scopes.go
@@ -18,6 +18,12 @@ const (
SCOPE_CALLBACK_READ = "callback.read"
SCOPE_CALLBACK_WRITE = "callback.write"
+ SCOPE_CHAT_READ = "chat.read"
+ SCOPE_CHAT_WRITE = "chat.write"
+
+ SCOPE_CHAT_AI_READ = "chat-ai.read"
+ SCOPE_CHAT_AI_WRITE = "chat-ai.write"
+
SCOPE_CONTAINER_FILE_READ = "container_file.read"
SCOPE_CONTAINER_FILE_WRITE = "container_file.write"
@@ -85,6 +91,10 @@ var scopeDefinitions = []ScopeDefinition{
{Name: SCOPE_C2_WRITE, DisplayName: "Write C2 profiles", Description: "Start, stop, update, or interact with C2 profile containers. Includes read access.", Resource: "c2", Access: "write", Includes: []string{SCOPE_C2_READ}},
{Name: SCOPE_CALLBACK_READ, DisplayName: "Read callbacks", Description: "View callbacks and callback metadata for accessible operations.", Resource: "callback", Access: "read"},
{Name: SCOPE_CALLBACK_WRITE, DisplayName: "Write callbacks", Description: "Create or update callback state. Includes read access.", Resource: "callback", Access: "write", Includes: []string{SCOPE_CALLBACK_READ}},
+ {Name: SCOPE_CHAT_READ, DisplayName: "Read operation chat", Description: "View and search standard chat channels and messages for accessible operations.", Resource: "chat", Access: "read"},
+ {Name: SCOPE_CHAT_WRITE, DisplayName: "Write operation chat", Description: "Create standard chat channels and post, edit, or delete standard chat messages. Includes read access.", Resource: "chat", Access: "write", Includes: []string{SCOPE_CHAT_READ}},
+ {Name: SCOPE_CHAT_AI_READ, DisplayName: "Read AI chat", Description: "View and search AI-backed chat sessions and messages for accessible operations.", Resource: "chat-ai", Access: "read"},
+ {Name: SCOPE_CHAT_AI_WRITE, DisplayName: "Write AI chat", Description: "Create AI chat sessions, post prompts, and receive AI chat responses. Includes read access.", Resource: "chat-ai", Access: "write", Includes: []string{SCOPE_CHAT_AI_READ}},
{Name: SCOPE_CONTAINER_FILE_READ, DisplayName: "Read container files", Description: "List and download files exposed by Mythic service containers.", Resource: "container_file", Access: "read"},
{Name: SCOPE_CONTAINER_FILE_WRITE, DisplayName: "Write container files", Description: "Write or remove files exposed by Mythic service containers. Includes read access.", Resource: "container_file", Access: "write", Includes: []string{SCOPE_CONTAINER_FILE_READ}},
{Name: SCOPE_CREDENTIAL_READ, DisplayName: "Read credentials", Description: "View credential records for accessible operations.", Resource: "credential", Access: "read"},
diff --git a/mythic-docker/src/authentication/mythicjwt/scopes_test.go b/mythic-docker/src/authentication/mythicjwt/scopes_test.go
index fdb95e7a..0cf2e0ea 100644
--- a/mythic-docker/src/authentication/mythicjwt/scopes_test.go
+++ b/mythic-docker/src/authentication/mythicjwt/scopes_test.go
@@ -103,6 +103,18 @@ func TestAllowsScope(t *testing.T) {
required: SCOPE_FILE_WRITE,
want: true,
},
+ {
+ name: "hyphenated resource wildcard grants ai chat scope",
+ granted: []string{"chat-ai.*"},
+ required: SCOPE_CHAT_AI_WRITE,
+ want: true,
+ },
+ {
+ name: "chat write does not grant ai chat write",
+ granted: []string{SCOPE_CHAT_WRITE},
+ required: SCOPE_CHAT_AI_WRITE,
+ want: false,
+ },
{
name: "file write grants upload",
granted: []string{SCOPE_FILE_WRITE},
@@ -232,6 +244,16 @@ func TestHasuraScopeRequirementsUseScalarClaims(t *testing.T) {
wantAnchor: "operation",
wantClaim: "x-hasura-scope-operation-write-operation",
},
+ {
+ scope: SCOPE_CHAT_READ,
+ wantAnchor: "operation",
+ wantClaim: "x-hasura-scope-chat-read-operation",
+ },
+ {
+ scope: SCOPE_CHAT_AI_WRITE,
+ wantAnchor: "operation",
+ wantClaim: "x-hasura-scope-chat-ai-write-operation",
+ },
}
for _, tt := range tests {
diff --git a/mythic-docker/src/database/migrations/003003020_3.3.20.sql b/mythic-docker/src/database/migrations/003003020_3.3.20.sql
index cbc79063..6c21a9ad 100644
--- a/mythic-docker/src/database/migrations/003003020_3.3.20.sql
+++ b/mythic-docker/src/database/migrations/003003020_3.3.20.sql
@@ -92,6 +92,225 @@ create index if not exists operationeventlog_unresolved_warning_source_operation
on "public"."operationeventlog" using btree (source, operation_id, "level")
where warning=true and resolved=false and deleted=false;
+create table if not exists "public"."chat_channel" (
+ id integer generated by default as identity primary key,
+ operation_id integer not null references "public"."operation"(id) on delete cascade,
+ name text not null,
+ slug text not null,
+ description text not null default '',
+ channel_type text not null default 'standard',
+ created_by integer not null references "public"."operator"(id) on delete restrict,
+ archived boolean not null default false,
+ archived_by integer references "public"."operator"(id) on delete set null,
+ archived_at timestamp without time zone,
+ locked boolean not null default false,
+ locked_by integer references "public"."operator"(id) on delete set null,
+ locked_at timestamp without time zone,
+ chat_container_id integer references "public"."consuming_container"(id) on delete set null,
+ chat_model text not null default '',
+ ai_metadata jsonb not null default jsonb_build_object(),
+ created_at timestamp without time zone not null default now(),
+ updated_at timestamp without time zone not null default now(),
+ constraint chat_channel_type_check check (channel_type in ('standard', 'ai')),
+ constraint chat_channel_ai_container_check check (channel_type <> 'ai' or chat_container_id is not null)
+);
+
+create unique index if not exists chat_channel_operation_slug_unique
+on "public"."chat_channel" using btree (operation_id, lower(slug));
+
+create index if not exists chat_channel_operation_type_archived_idx
+on "public"."chat_channel" using btree (operation_id, channel_type, archived, name);
+
+create or replace trigger set_public_chat_channel_updated_at
+before update on "public"."chat_channel"
+for each row execute function public.set_current_timestamp_updated_at();
+
+create table if not exists "public"."chat_message" (
+ id integer generated by default as identity primary key,
+ operation_id integer not null references "public"."operation"(id) on delete cascade,
+ channel_id integer not null references "public"."chat_channel"(id) on delete cascade,
+ operator_id integer references "public"."operator"(id) on delete set null,
+ apitokens_id integer references "public"."apitokens"(id) on delete set null,
+ author_type text not null default 'operator',
+ chat_container_id integer references "public"."consuming_container"(id) on delete set null,
+ sender_display_name text not null default '',
+ message text not null default '',
+ deleted boolean not null default false,
+ search_vector tsvector generated always as (
+ to_tsvector('simple', case when deleted then '' else coalesce(message, '') end)
+ ) stored,
+ edited boolean not null default false,
+ edited_at timestamp without time zone,
+ deleted_by integer references "public"."operator"(id) on delete set null,
+ deleted_at timestamp without time zone,
+ status text not null default 'complete',
+ metadata jsonb not null default jsonb_build_object(),
+ created_at timestamp without time zone not null default now(),
+ updated_at timestamp without time zone not null default now(),
+ constraint chat_message_author_type_check check (author_type in ('operator', 'ai', 'system')),
+ constraint chat_message_status_check check (status in ('pending', 'streaming', 'complete', 'error', 'cancelled'))
+);
+
+create index if not exists chat_message_operation_channel_id_idx
+on "public"."chat_message" using btree (operation_id, channel_id, id desc);
+
+create index if not exists chat_message_operation_updated_idx
+on "public"."chat_message" using btree (operation_id, updated_at desc);
+
+create index if not exists chat_message_operator_idx
+on "public"."chat_message" using btree (operator_id);
+
+create index if not exists chat_message_search_vector_idx
+on "public"."chat_message" using gin (search_vector);
+
+create or replace trigger set_public_chat_message_updated_at
+before update on "public"."chat_message"
+for each row execute function public.set_current_timestamp_updated_at();
+
+alter table "public"."chat_channel"
+ add column if not exists last_message_id integer;
+
+update "public"."chat_channel" channel
+set last_message_id = latest.id
+from (
+ select distinct on (channel_id)
+ channel_id,
+ id
+ from "public"."chat_message"
+ order by channel_id, id desc
+) latest
+where channel.id = latest.channel_id
+and channel.last_message_id is distinct from latest.id;
+
+create index if not exists chat_channel_operation_unread_idx
+on "public"."chat_channel" using btree (operation_id, archived, last_message_id);
+
+-- +migrate StatementBegin
+create or replace function public.update_chat_channel_last_message_id() returns trigger
+ language plpgsql
+ as $$
+begin
+ update "public"."chat_channel"
+ set last_message_id = new.id,
+ updated_at = now()
+ where id = new.channel_id
+ and (last_message_id is null or last_message_id < new.id);
+
+ return new;
+end;
+$$;
+-- +migrate StatementEnd
+
+create or replace trigger update_chat_channel_last_message_id_trigger
+after insert on "public"."chat_message"
+for each row execute function public.update_chat_channel_last_message_id();
+
+create table if not exists "public"."chat_request" (
+ id integer generated by default as identity primary key,
+ operation_id integer not null references "public"."operation"(id) on delete cascade,
+ channel_id integer not null references "public"."chat_channel"(id) on delete cascade,
+ request_message_id integer not null references "public"."chat_message"(id) on delete cascade,
+ response_message_id integer not null references "public"."chat_message"(id) on delete cascade,
+ chat_container_id integer not null references "public"."consuming_container"(id) on delete restrict,
+ model text not null default '',
+ status text not null default 'pending',
+ error text not null default '',
+ context_snapshot jsonb not null default jsonb_build_object(),
+ retry_of_id integer references "public"."chat_request"(id) on delete set null,
+ created_by integer not null references "public"."operator"(id) on delete restrict,
+ created_at timestamp without time zone not null default now(),
+ updated_at timestamp without time zone not null default now(),
+ completed_at timestamp without time zone,
+ cancelled_at timestamp without time zone,
+ constraint chat_request_status_check check (status in ('pending', 'streaming', 'complete', 'error', 'cancelled'))
+);
+
+create index if not exists chat_request_operation_channel_idx
+on "public"."chat_request" using btree (operation_id, channel_id, id desc);
+
+create index if not exists chat_request_response_message_id_idx
+on "public"."chat_request" using btree (response_message_id);
+
+create or replace trigger set_public_chat_request_updated_at
+before update on "public"."chat_request"
+for each row execute function public.set_current_timestamp_updated_at();
+
+create table if not exists "public"."chat_read_state" (
+ operation_id integer not null references "public"."operation"(id) on delete cascade,
+ channel_id integer not null references "public"."chat_channel"(id) on delete cascade,
+ operator_id integer not null references "public"."operator"(id) on delete cascade,
+ last_read_message_id integer references "public"."chat_message"(id) on delete set null,
+ updated_at timestamp without time zone not null default now(),
+ primary key (operator_id, channel_id)
+);
+
+create index if not exists chat_read_state_operation_operator_idx
+on "public"."chat_read_state" using btree (operation_id, operator_id);
+
+create or replace trigger set_public_chat_read_state_updated_at
+before update on "public"."chat_read_state"
+for each row execute function public.set_current_timestamp_updated_at();
+
+-- +migrate StatementBegin
+create or replace function public.create_default_chat_channel_for_operation() returns trigger
+ language plpgsql
+ as $$
+begin
+ insert into "public"."chat_channel" (
+ operation_id,
+ name,
+ slug,
+ description,
+ channel_type,
+ created_by
+ )
+ values (
+ new.id,
+ 'general',
+ 'general',
+ 'Default operation chat channel',
+ 'standard',
+ new.admin_id
+ )
+ on conflict do nothing;
+
+ return new;
+end;
+$$;
+-- +migrate StatementEnd
+
+create or replace trigger create_default_chat_channel_for_operation_trigger
+after insert on "public"."operation"
+for each row execute function public.create_default_chat_channel_for_operation();
+
+insert into "public"."chat_channel" (
+ operation_id,
+ name,
+ slug,
+ description,
+ channel_type,
+ created_by
+)
+select
+ operation.id,
+ 'general',
+ 'general',
+ 'Default operation chat channel',
+ 'standard',
+ operation.admin_id
+from "public"."operation"
+on conflict do nothing;
+
+update "public"."chat_channel"
+set
+ name = 'general',
+ archived = false,
+ archived_by = null,
+ archived_at = null
+where channel_type = 'standard'
+ and lower(slug) = 'general'
+ and (name <> 'general' or archived = true or archived_by is not null or archived_at is not null);
+
alter table "public"."task"
add column if not exists subtask_callback_function_started boolean not null default false,
add column if not exists group_callback_function_started boolean not null default false,
@@ -227,6 +446,15 @@ drop index if exists "public"."mythictree_operation_tree_timestamp_idx";
drop index if exists "public"."mythictree_operation_tree_host_full_callback_idx";
drop index if exists "public"."mythictree_operation_tree_host_parent_callback_idx";
drop index if exists "public"."operationeventlog_unresolved_warning_source_operation_level_idx";
+drop index if exists "public"."chat_channel_operation_unread_idx";
+drop trigger if exists update_chat_channel_last_message_id_trigger on "public"."chat_message";
+drop function if exists public.update_chat_channel_last_message_id();
+drop trigger if exists create_default_chat_channel_for_operation_trigger on "public"."operation";
+drop function if exists public.create_default_chat_channel_for_operation();
+drop table if exists "public"."chat_read_state";
+drop table if exists "public"."chat_request";
+drop table if exists "public"."chat_message";
+drop table if exists "public"."chat_channel";
alter table "public"."task"
drop column if exists completed_callback_function_started,
drop column if exists group_callback_function_started,
diff --git a/mythic-docker/src/database/structs/Chat.go b/mythic-docker/src/database/structs/Chat.go
new file mode 100644
index 00000000..80d81117
--- /dev/null
+++ b/mythic-docker/src/database/structs/Chat.go
@@ -0,0 +1,111 @@
+package databaseStructs
+
+import (
+ "database/sql"
+ "time"
+
+ "github.com/its-a-feature/Mythic/utils/structs"
+)
+
+const (
+ ChatChannelTypeStandard = "standard"
+ ChatChannelTypeAI = "ai"
+
+ ChatMessageAuthorOperator = "operator"
+ ChatMessageAuthorAI = "ai"
+ ChatMessageAuthorSystem = "system"
+
+ ChatMessageStatusPending = "pending"
+ ChatMessageStatusStreaming = "streaming"
+ ChatMessageStatusComplete = "complete"
+ ChatMessageStatusError = "error"
+ ChatMessageStatusCancelled = "cancelled"
+)
+
+type ChatChannel struct {
+ ID int `db:"id" json:"id"`
+ OperationID int `db:"operation_id" json:"operation_id"`
+ Operation Operation `db:"operation" json:"operation,omitempty"`
+ Name string `db:"name" json:"name"`
+ Slug string `db:"slug" json:"slug"`
+ Description string `db:"description" json:"description"`
+ ChannelType string `db:"channel_type" json:"channel_type"`
+ CreatedBy int `db:"created_by" json:"created_by"`
+ Creator Operator `db:"creator" json:"creator,omitempty"`
+ Archived bool `db:"archived" json:"archived"`
+ ArchivedBy structs.NullInt64 `db:"archived_by" json:"archived_by"`
+ ArchivedAt sql.NullTime `db:"archived_at" json:"archived_at"`
+ Locked bool `db:"locked" json:"locked"`
+ LockedBy structs.NullInt64 `db:"locked_by" json:"locked_by"`
+ LockedAt sql.NullTime `db:"locked_at" json:"locked_at"`
+ LastMessageID structs.NullInt64 `db:"last_message_id" json:"last_message_id"`
+ ChatContainerID structs.NullInt64 `db:"chat_container_id" json:"chat_container_id"`
+ ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"`
+ ChatModel string `db:"chat_model" json:"chat_model"`
+ AIMetadata MythicJSONText `db:"ai_metadata" json:"ai_metadata"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
+
+type ChatMessage struct {
+ ID int `db:"id" json:"id"`
+ OperationID int `db:"operation_id" json:"operation_id"`
+ Operation Operation `db:"operation" json:"operation,omitempty"`
+ ChannelID int `db:"channel_id" json:"channel_id"`
+ Channel ChatChannel `db:"channel" json:"channel,omitempty"`
+ OperatorID structs.NullInt64 `db:"operator_id" json:"operator_id"`
+ Operator Operator `db:"operator" json:"operator,omitempty"`
+ APITokensID structs.NullInt64 `db:"apitokens_id" json:"api_tokens_id" mapstructure:"apitokens_id"`
+ APIToken Apitokens `db:"apitoken" json:"apitoken,omitempty"`
+ AuthorType string `db:"author_type" json:"author_type"`
+ ChatContainerID structs.NullInt64 `db:"chat_container_id" json:"chat_container_id"`
+ ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"`
+ SenderDisplayName string `db:"sender_display_name" json:"sender_display_name"`
+ Message string `db:"message" json:"message"`
+ Edited bool `db:"edited" json:"edited"`
+ EditedAt sql.NullTime `db:"edited_at" json:"edited_at"`
+ Deleted bool `db:"deleted" json:"deleted"`
+ DeletedBy structs.NullInt64 `db:"deleted_by" json:"deleted_by"`
+ DeletedAt sql.NullTime `db:"deleted_at" json:"deleted_at"`
+ Status string `db:"status" json:"status"`
+ Metadata MythicJSONText `db:"metadata" json:"metadata"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
+
+type ChatRequest struct {
+ ID int `db:"id" json:"id"`
+ OperationID int `db:"operation_id" json:"operation_id"`
+ Operation Operation `db:"operation" json:"operation,omitempty"`
+ ChannelID int `db:"channel_id" json:"channel_id"`
+ Channel ChatChannel `db:"channel" json:"channel,omitempty"`
+ RequestMessageID int `db:"request_message_id" json:"request_message_id"`
+ RequestMessage ChatMessage `db:"request_message" json:"request_message,omitempty"`
+ ResponseMessageID int `db:"response_message_id" json:"response_message_id"`
+ ResponseMessage ChatMessage `db:"response_message" json:"response_message,omitempty"`
+ ChatContainerID int `db:"chat_container_id" json:"chat_container_id"`
+ ChatContainer ConsumingContainer `db:"chat_container" json:"chat_container,omitempty"`
+ Model string `db:"model" json:"model"`
+ Status string `db:"status" json:"status"`
+ Error string `db:"error" json:"error"`
+ ContextSnapshot MythicJSONText `db:"context_snapshot" json:"context_snapshot"`
+ RetryOfID structs.NullInt64 `db:"retry_of_id" json:"retry_of_id"`
+ CreatedBy int `db:"created_by" json:"created_by"`
+ Creator Operator `db:"creator" json:"creator,omitempty"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+ CompletedAt sql.NullTime `db:"completed_at" json:"completed_at"`
+ CancelledAt sql.NullTime `db:"cancelled_at" json:"cancelled_at"`
+}
+
+type ChatReadState struct {
+ OperationID int `db:"operation_id" json:"operation_id"`
+ Operation Operation `db:"operation" json:"operation,omitempty"`
+ ChannelID int `db:"channel_id" json:"channel_id"`
+ Channel ChatChannel `db:"channel" json:"channel,omitempty"`
+ OperatorID int `db:"operator_id" json:"operator_id"`
+ Operator Operator `db:"operator" json:"operator,omitempty"`
+ LastReadMessageID structs.NullInt64 `db:"last_read_message_id" json:"last_read_message_id"`
+ LastReadMessage ChatMessage `db:"last_read_message" json:"last_read_message,omitempty"`
+ UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
+}
diff --git a/mythic-docker/src/rabbitmq/check_container_status.go b/mythic-docker/src/rabbitmq/check_container_status.go
index ec4e5668..272f2833 100644
--- a/mythic-docker/src/rabbitmq/check_container_status.go
+++ b/mythic-docker/src/rabbitmq/check_container_status.go
@@ -205,6 +205,8 @@ func CreateGraphQLSpectatorAPITokenAndSendOnStartMessage(containerName string) {
mythicjwt.SCOPE_FILE_WRITE,
mythicjwt.SCOPE_TAG_WRITE,
mythicjwt.SCOPE_CALLBACK_WRITE,
+ mythicjwt.SCOPE_CHAT_AI_READ,
+ mythicjwt.SCOPE_CHAT_AI_WRITE,
}
statement, err := database.DB.PrepareNamed(`INSERT INTO apitokens
(token_value, operator_id, token_type, active, "name", created_by, task_id, callback_id, scopes)
diff --git a/mythic-docker/src/rabbitmq/constants.go b/mythic-docker/src/rabbitmq/constants.go
index e6e04bb8..b3def864 100644
--- a/mythic-docker/src/rabbitmq/constants.go
+++ b/mythic-docker/src/rabbitmq/constants.go
@@ -89,6 +89,9 @@ const (
CONTAINER_ON_START = "container_on_start"
CONTAINER_ON_START_RESPONSE = "container_on_start_response"
+ // CHAT
+ CHAT_RESPONSE_ROUTING_KEY = "chat_response"
+
// CUSTOM_BROWSER routes
CUSTOMBROWSER_SYNC_ROUTING_KEY = "custombrowser_sync"
CUSTOMBROWSER_EXPORT_FUNCTION = "custombrowser_exportfunction"
@@ -124,6 +127,7 @@ const (
//
PT_TASK_PROCESS_RESPONSE = "pt_task_process_response"
PT_COMMAND_HELP_FUNCTION = "pt_command_help_function"
+ CHAT_REQUEST = "chat_request"
)
// Routes where container is consuming messages and responding back to Mythic
diff --git a/mythic-docker/src/rabbitmq/initialize.go b/mythic-docker/src/rabbitmq/initialize.go
index 6b6ccb5c..4732e41d 100644
--- a/mythic-docker/src/rabbitmq/initialize.go
+++ b/mythic-docker/src/rabbitmq/initialize.go
@@ -31,6 +31,7 @@ type DirectQueueStruct struct {
RoutingKey string
Handler QueueHandler
Scopes []string
+ Sequential bool
}
type channelMutex struct {
@@ -90,6 +91,7 @@ func (r *rabbitMQConnection) startListeners() {
directQueue.Queue,
directQueue.RoutingKey,
directQueue.Handler,
+ directQueue.Sequential,
exclusiveQueue)
}
go checkContainerStatus()
diff --git a/mythic-docker/src/rabbitmq/recv_chat_response.go b/mythic-docker/src/rabbitmq/recv_chat_response.go
new file mode 100644
index 00000000..d9c8d626
--- /dev/null
+++ b/mythic-docker/src/rabbitmq/recv_chat_response.go
@@ -0,0 +1,304 @@
+package rabbitmq
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/its-a-feature/Mythic/authentication/mythicjwt"
+ "github.com/its-a-feature/Mythic/database"
+ databaseStructs "github.com/its-a-feature/Mythic/database/structs"
+ "github.com/its-a-feature/Mythic/logging"
+ amqp "github.com/rabbitmq/amqp091-go"
+)
+
+const chatStreamFlushInterval = 500 * time.Millisecond
+
+type ChatContainerResponseMessage struct {
+ OperationID int `json:"operation_id" mapstructure:"operation_id"`
+ RequestID int `json:"request_id" mapstructure:"request_id"`
+ ResponseMessageID int `json:"response_message_id" mapstructure:"response_message_id"`
+ Content string `json:"content" mapstructure:"content"`
+ IsDelta bool `json:"is_delta" mapstructure:"is_delta"`
+ Complete bool `json:"complete" mapstructure:"complete"`
+ Status string `json:"status" mapstructure:"status"`
+ Error string `json:"error" mapstructure:"error"`
+ Metadata map[string]interface{} `json:"metadata" mapstructure:"metadata"`
+}
+
+type chatResponseRequest struct {
+ ID int `db:"id"`
+ OperationID int `db:"operation_id"`
+ ResponseMessageID int `db:"response_message_id"`
+ Status string `db:"status"`
+}
+
+type chatStreamBuffer struct {
+ OperationID int
+ Pending string
+ Timer *time.Timer
+}
+
+var (
+ chatStreamBuffers = map[int]*chatStreamBuffer{}
+ chatStreamBuffersLock sync.Mutex
+ chatResponseLocks sync.Map
+)
+
+func init() {
+ RabbitMQConnection.AddDirectQueue(DirectQueueStruct{
+ Exchange: MYTHIC_EXCHANGE,
+ Queue: CHAT_RESPONSE_ROUTING_KEY,
+ RoutingKey: CHAT_RESPONSE_ROUTING_KEY,
+ Handler: processChatContainerResponse,
+ Scopes: []string{mythicjwt.SCOPE_CHAT_AI_WRITE},
+ Sequential: true,
+ })
+}
+
+func processChatContainerResponse(msg amqp.Delivery) {
+ incomingMessage := ChatContainerResponseMessage{}
+ if err := json.Unmarshal(msg.Body, &incomingMessage); err != nil {
+ logging.LogError(err, "Failed to process chat container response message")
+ return
+ }
+ authContext, err := GetRabbitMQAuthContextFromHeaders(msg.Headers)
+ if err != nil {
+ logging.LogError(err, "Failed to get chat response auth headers")
+ return
+ }
+ if err = applyChatContainerResponse(incomingMessage, authContext); err != nil {
+ logging.LogError(err, "Failed to apply chat container response",
+ "request_id", incomingMessage.RequestID, "response_message_id", incomingMessage.ResponseMessageID)
+ }
+}
+
+func applyChatContainerResponse(incomingMessage ChatContainerResponseMessage, authContext RabbitMQAuthContext) error {
+ request, err := getChatResponseRequest(incomingMessage, authContext.OperationID)
+ if err != nil {
+ return err
+ }
+ if request.Status == databaseStructs.ChatMessageStatusCancelled {
+ return nil
+ }
+ if incomingMessage.OperationID > 0 && incomingMessage.OperationID != request.OperationID {
+ return fmt.Errorf("chat response operation %d does not match request operation %d",
+ incomingMessage.OperationID, request.OperationID)
+ }
+ if authContext.OperationID > 0 && authContext.OperationID != request.OperationID {
+ return fmt.Errorf("chat response auth operation %d does not match request operation %d",
+ authContext.OperationID, request.OperationID)
+ }
+ if incomingMessage.ResponseMessageID > 0 && incomingMessage.ResponseMessageID != request.ResponseMessageID {
+ return fmt.Errorf("chat response message %d does not match request response message %d",
+ incomingMessage.ResponseMessageID, request.ResponseMessageID)
+ }
+
+ chatResponseLock := getChatResponseLock(request.ResponseMessageID)
+ chatResponseLock.Lock()
+ defer chatResponseLock.Unlock()
+
+ if request, err = getChatResponseRequest(ChatContainerResponseMessage{RequestID: request.ID}, request.OperationID); err != nil {
+ return err
+ }
+ if isTerminalChatResponseStatus(request.Status) {
+ return nil
+ }
+
+ status := normalizeChatContainerResponseStatus(incomingMessage)
+ if incomingMessage.Content != "" {
+ if incomingMessage.IsDelta {
+ if err = queueChatResponseDelta(request.ResponseMessageID, request.OperationID, incomingMessage.Content); err != nil {
+ return err
+ }
+ } else {
+ if err = flushChatResponseMessageLocked(request.ResponseMessageID, request.OperationID, false); err != nil {
+ return err
+ }
+ if err = setChatResponseMessageContent(request.ResponseMessageID, request.OperationID, incomingMessage.Content); err != nil {
+ return err
+ }
+ }
+ if status == "" {
+ status = databaseStructs.ChatMessageStatusStreaming
+ }
+ }
+
+ if status == databaseStructs.ChatMessageStatusComplete ||
+ status == databaseStructs.ChatMessageStatusError ||
+ status == databaseStructs.ChatMessageStatusCancelled {
+ if err = flushChatResponseMessageLocked(request.ResponseMessageID, request.OperationID, true); err != nil {
+ return err
+ }
+ }
+ if status == "" && len(incomingMessage.Metadata) > 0 {
+ status = request.Status
+ }
+ if status != "" {
+ if err = setChatResponseStatus(request, status, incomingMessage.Error, incomingMessage.Metadata); err != nil {
+ return err
+ }
+ if isTerminalChatResponseStatus(status) {
+ chatResponseLocks.Delete(request.ResponseMessageID)
+ }
+ }
+ return nil
+}
+
+func getChatResponseRequest(incomingMessage ChatContainerResponseMessage, operationID int) (chatResponseRequest, error) {
+ request := chatResponseRequest{}
+ if incomingMessage.RequestID <= 0 && incomingMessage.ResponseMessageID <= 0 {
+ return request, errors.New("chat response requires request_id or response_message_id")
+ }
+ whereClause := "id=$1"
+ arg := incomingMessage.RequestID
+ if incomingMessage.RequestID <= 0 {
+ whereClause = "response_message_id=$1"
+ arg = incomingMessage.ResponseMessageID
+ }
+ sqlStatement := fmt.Sprintf(`SELECT id, operation_id, response_message_id, status
+ FROM chat_request
+ WHERE %s`, whereClause)
+ args := []interface{}{arg}
+ if operationID > 0 {
+ sqlStatement += " AND operation_id=$2"
+ args = append(args, operationID)
+ }
+ if err := database.DB.Get(&request, sqlStatement, args...); err != nil {
+ return request, err
+ }
+ return request, nil
+}
+
+func normalizeChatContainerResponseStatus(incomingMessage ChatContainerResponseMessage) string {
+ status := strings.ToLower(strings.TrimSpace(incomingMessage.Status))
+ switch status {
+ case databaseStructs.ChatMessageStatusStreaming,
+ databaseStructs.ChatMessageStatusComplete,
+ databaseStructs.ChatMessageStatusError,
+ databaseStructs.ChatMessageStatusCancelled:
+ return status
+ }
+ if incomingMessage.Error != "" {
+ return databaseStructs.ChatMessageStatusError
+ }
+ if incomingMessage.Complete {
+ return databaseStructs.ChatMessageStatusComplete
+ }
+ return ""
+}
+
+func isTerminalChatResponseStatus(status string) bool {
+ return status == databaseStructs.ChatMessageStatusComplete ||
+ status == databaseStructs.ChatMessageStatusError ||
+ status == databaseStructs.ChatMessageStatusCancelled
+}
+
+func getChatResponseLock(responseMessageID int) *sync.Mutex {
+ lock, _ := chatResponseLocks.LoadOrStore(responseMessageID, &sync.Mutex{})
+ return lock.(*sync.Mutex)
+}
+
+func queueChatResponseDelta(responseMessageID int, operationID int, delta string) error {
+ chatStreamBuffersLock.Lock()
+ defer chatStreamBuffersLock.Unlock()
+ buffer, ok := chatStreamBuffers[responseMessageID]
+ if !ok {
+ buffer = &chatStreamBuffer{OperationID: operationID}
+ chatStreamBuffers[responseMessageID] = buffer
+ }
+ if buffer.OperationID > 0 && operationID > 0 && buffer.OperationID != operationID {
+ return fmt.Errorf("chat response buffer operation %d does not match incoming operation %d for response message %d",
+ buffer.OperationID, operationID, responseMessageID)
+ }
+ if buffer.OperationID == 0 {
+ buffer.OperationID = operationID
+ }
+ buffer.Pending += delta
+ if buffer.Timer == nil {
+ buffer.Timer = time.AfterFunc(chatStreamFlushInterval, func() {
+ if err := flushChatResponseMessage(responseMessageID, operationID, false); err != nil {
+ logging.LogError(err, "Failed to flush chat response buffer", "response_message_id", responseMessageID)
+ }
+ })
+ }
+ return nil
+}
+
+func flushChatResponseMessage(responseMessageID int, operationID int, final bool) error {
+ chatResponseLock := getChatResponseLock(responseMessageID)
+ chatResponseLock.Lock()
+ defer chatResponseLock.Unlock()
+ return flushChatResponseMessageLocked(responseMessageID, operationID, final)
+}
+
+func flushChatResponseMessageLocked(responseMessageID int, operationID int, final bool) error {
+ chatStreamBuffersLock.Lock()
+ buffer, ok := chatStreamBuffers[responseMessageID]
+ if !ok {
+ chatStreamBuffersLock.Unlock()
+ return nil
+ }
+ if buffer.OperationID > 0 && operationID > 0 && buffer.OperationID != operationID {
+ chatStreamBuffersLock.Unlock()
+ return fmt.Errorf("chat response buffer operation %d does not match flush operation %d for response message %d",
+ buffer.OperationID, operationID, responseMessageID)
+ }
+ pending := buffer.Pending
+ buffer.Pending = ""
+ if buffer.Timer != nil {
+ buffer.Timer.Stop()
+ buffer.Timer = nil
+ }
+ if final {
+ delete(chatStreamBuffers, responseMessageID)
+ }
+ chatStreamBuffersLock.Unlock()
+
+ if pending == "" {
+ return nil
+ }
+ _, err := database.DB.Exec(`UPDATE chat_message
+ SET message = message || $2,
+ status = CASE WHEN status IN ('pending', 'streaming') THEN 'streaming' ELSE status END
+ WHERE id=$1 AND operation_id=$3 AND deleted=false`, responseMessageID, pending, operationID)
+ return err
+}
+
+func setChatResponseMessageContent(responseMessageID int, operationID int, content string) error {
+ _, err := database.DB.Exec(`UPDATE chat_message
+ SET message=$3,
+ status = CASE WHEN status IN ('pending', 'streaming') THEN 'streaming' ELSE status END
+ WHERE id=$1 AND operation_id=$2 AND deleted=false`, responseMessageID, operationID, content)
+ return err
+}
+
+func setChatResponseStatus(request chatResponseRequest, status string, responseError string, metadata map[string]interface{}) error {
+ if len(metadata) == 0 {
+ metadata = map[string]interface{}{}
+ }
+ metadataJSON := GetMythicJSONTextFromStruct(metadata)
+ if responseError != "" {
+ metadataJSON = GetMythicJSONTextFromStruct(map[string]interface{}{
+ "container_metadata": metadata,
+ "error": responseError,
+ })
+ }
+ _, err := database.DB.Exec(`UPDATE chat_message
+ SET status=$3,
+ metadata = metadata || $4::jsonb
+ WHERE id=$1 AND operation_id=$2 AND deleted=false`, request.ResponseMessageID, request.OperationID, status, metadataJSON.String())
+ if err != nil {
+ return err
+ }
+ _, err = database.DB.Exec(`UPDATE chat_request
+ SET status=$2,
+ error=$3,
+ completed_at=CASE WHEN $2='complete' THEN now() ELSE completed_at END,
+ cancelled_at=CASE WHEN $2='cancelled' THEN now() ELSE cancelled_at END
+ WHERE id=$1 AND operation_id=$4 AND status <> 'cancelled'`, request.ID, status, responseError, request.OperationID)
+ return err
+}
diff --git a/mythic-docker/src/rabbitmq/recv_consuming_container_sync.go b/mythic-docker/src/rabbitmq/recv_consuming_container_sync.go
index 2b9c2cbb..0f6015fa 100644
--- a/mythic-docker/src/rabbitmq/recv_consuming_container_sync.go
+++ b/mythic-docker/src/rabbitmq/recv_consuming_container_sync.go
@@ -38,6 +38,7 @@ const (
CONSUMING_SERVICES_TYPE_WEBHOOK = "webhook"
CONSUMING_SERVICES_TYPE_EVENTING = "eventing"
CONSUMING_SERVICES_TYPE_SCRIPTING = "scripting"
+ CONSUMING_SERVICES_TYPE_CHAT = "chat"
)
func init() {
diff --git a/mythic-docker/src/rabbitmq/send_chat_request.go b/mythic-docker/src/rabbitmq/send_chat_request.go
new file mode 100644
index 00000000..eca7c235
--- /dev/null
+++ b/mythic-docker/src/rabbitmq/send_chat_request.go
@@ -0,0 +1,51 @@
+package rabbitmq
+
+import (
+ "time"
+
+ "github.com/its-a-feature/Mythic/logging"
+)
+
+type ChatContainerContextMessage struct {
+ ID int `db:"id" json:"id" mapstructure:"id"`
+ AuthorType string `db:"author_type" json:"author_type" mapstructure:"author_type"`
+ SenderDisplayName string `db:"sender_display_name" json:"sender_display_name" mapstructure:"sender_display_name"`
+ Message string `db:"message" json:"message" mapstructure:"message"`
+ CreatedAt time.Time `db:"created_at" json:"created_at" mapstructure:"created_at"`
+}
+
+type ChatContainerRequestMessage struct {
+ ContainerName string `json:"container_name" mapstructure:"container_name"`
+ OperationID int `json:"operation_id" mapstructure:"operation_id"`
+ ChannelID int `json:"channel_id" mapstructure:"channel_id"`
+ ChannelName string `json:"channel_name" mapstructure:"channel_name"`
+ ChannelSlug string `json:"channel_slug" mapstructure:"channel_slug"`
+ RequestID int `json:"request_id" mapstructure:"request_id"`
+ RequestMessageID int `json:"request_message_id" mapstructure:"request_message_id"`
+ ResponseMessageID int `json:"response_message_id" mapstructure:"response_message_id"`
+ Model string `json:"model" mapstructure:"model"`
+ Prompt string `json:"prompt" mapstructure:"prompt"`
+ Context []ChatContainerContextMessage `json:"context" mapstructure:"context"`
+ Secrets map[string]interface{} `json:"secrets" mapstructure:"secrets"`
+}
+
+func (r *rabbitMQConnection) SendChatContainerRequest(containerName string, chatMessage ChatContainerRequestMessage, authContext RabbitMQAuthContext) error {
+ chatMessage.ContainerName = containerName
+ headers, err := GenerateRabbitMQAuthTokenHeader(authContext)
+ if err != nil {
+ logging.LogError(err, "Failed to generate auth context for chat request")
+ return err
+ }
+ if err = r.SendStructMessage(
+ MYTHIC_EXCHANGE,
+ GetChatContainerRequestRoutingKey(containerName),
+ "",
+ chatMessage,
+ false,
+ headers,
+ ); err != nil {
+ logging.LogError(err, "Failed to send chat request", "container", containerName, "request_id", chatMessage.RequestID)
+ return err
+ }
+ return nil
+}
diff --git a/mythic-docker/src/rabbitmq/utils_rabbitmq_routing.go b/mythic-docker/src/rabbitmq/utils_rabbitmq_routing.go
index e93f7adb..1be159a1 100644
--- a/mythic-docker/src/rabbitmq/utils_rabbitmq_routing.go
+++ b/mythic-docker/src/rabbitmq/utils_rabbitmq_routing.go
@@ -59,6 +59,9 @@ func GetPtTaskProcessResponseRoutingKey(container string) string {
func GetPtCommandHelpRoutingKey(container string) string {
return fmt.Sprintf("%s_%s", container, PT_COMMAND_HELP_FUNCTION)
}
+func GetChatContainerRequestRoutingKey(container string) string {
+ return fmt.Sprintf("%s_%s", container, CHAT_REQUEST)
+}
// c2 rpc routing key functions
func GetC2RPCOpsecChecksRoutingKey(container string) string {
@@ -519,7 +522,7 @@ func (r *rabbitMQConnection) SendRPCMessage(exchange string, queue string, body
logging.LogError(finalError, "failed 3 times")
return nil, finalError
}
-func (r *rabbitMQConnection) ReceiveFromMythicDirectExchange(exchange string, queue string, routingKey string, handler QueueHandler, exclusiveQueue bool) {
+func (r *rabbitMQConnection) ReceiveFromMythicDirectExchange(exchange string, queue string, routingKey string, handler QueueHandler, sequential bool, exclusiveQueue bool) {
// exchange is a direct exchange
// queue is where the messages get sent to (local name)
// routingKey is the specific direct topic we're interested in for the exchange
@@ -603,7 +606,11 @@ func (r *rabbitMQConnection) ReceiveFromMythicDirectExchange(exchange string, qu
logging.LogError(err, "RabbitMQ direct exchange auth check failed", "queue", queue)
continue
}
- go handler(d)
+ if sequential {
+ handler(d)
+ } else {
+ go handler(d)
+ }
}
forever <- true
}()
diff --git a/mythic-docker/src/webserver/controllers/chat_webhook.go b/mythic-docker/src/webserver/controllers/chat_webhook.go
new file mode 100644
index 00000000..0fbaa0fa
--- /dev/null
+++ b/mythic-docker/src/webserver/controllers/chat_webhook.go
@@ -0,0 +1,1177 @@
+package webcontroller
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "github.com/its-a-feature/Mythic/authentication"
+ "github.com/its-a-feature/Mythic/authentication/mythicjwt"
+ "github.com/its-a-feature/Mythic/database"
+ databaseStructs "github.com/its-a-feature/Mythic/database/structs"
+ "github.com/its-a-feature/Mythic/logging"
+ "github.com/its-a-feature/Mythic/rabbitmq"
+ "github.com/lib/pq"
+)
+
+const (
+ chatContextMessageLimit = 40
+ chatGeneralChannelName = "general"
+)
+
+var chatSlugInvalidCharacters = regexp.MustCompile(`[^a-z0-9]+`)
+
+type ChatActionResponse struct {
+ Status string `json:"status"`
+ Error string `json:"error,omitempty"`
+ ID int `json:"id,omitempty"`
+ ChannelID int `json:"channel_id,omitempty"`
+ MessageID int `json:"message_id,omitempty"`
+ RequestID int `json:"request_id,omitempty"`
+ ResponseMessageID int `json:"response_message_id,omitempty"`
+ Results []ChatSearchResult `json:"results,omitempty"`
+}
+
+type CreateChatChannelInput struct {
+ Input CreateChatChannel `json:"input" binding:"required"`
+}
+
+type CreateChatChannel struct {
+ Name string `json:"name" binding:"required"`
+ Description string `json:"description"`
+ ChannelType string `json:"channel_type"`
+ ChatContainerID *int `json:"chat_container_id"`
+ ChatModel string `json:"chat_model"`
+ Locked bool `json:"locked"`
+ AIMetadata interface{} `json:"ai_metadata"`
+}
+
+type UpdateChatChannelInput struct {
+ Input UpdateChatChannel `json:"input" binding:"required"`
+}
+
+type UpdateChatChannel struct {
+ ChannelID int `json:"channel_id" binding:"required"`
+ Name *string `json:"name"`
+ Description *string `json:"description"`
+ Archived *bool `json:"archived"`
+ Locked *bool `json:"locked"`
+ ChatModel *string `json:"chat_model"`
+ AIMetadata interface{} `json:"ai_metadata"`
+}
+
+type CreateChatMessageInput struct {
+ Input CreateChatMessage `json:"input" binding:"required"`
+}
+
+type CreateChatMessage struct {
+ ChannelID int `json:"channel_id" binding:"required"`
+ Message string `json:"message" binding:"required"`
+ SystemMessage bool `json:"system_message"`
+ AllOperations bool `json:"all_operations"`
+}
+
+type EditChatMessageInput struct {
+ Input EditChatMessage `json:"input" binding:"required"`
+}
+
+type EditChatMessage struct {
+ MessageID int `json:"message_id" binding:"required"`
+ Message string `json:"message" binding:"required"`
+}
+
+type DeleteChatMessageInput struct {
+ Input DeleteChatMessage `json:"input" binding:"required"`
+}
+
+type DeleteChatMessage struct {
+ MessageID int `json:"message_id" binding:"required"`
+}
+
+type MarkChatReadInput struct {
+ Input MarkChatRead `json:"input" binding:"required"`
+}
+
+type MarkChatRead struct {
+ ChannelID int `json:"channel_id" binding:"required"`
+ LastReadMessageID *int `json:"last_read_message_id"`
+}
+
+type ChatSearchInput struct {
+ Input ChatSearch `json:"input" binding:"required"`
+}
+
+type ChatSearch struct {
+ Query string `json:"query" binding:"required"`
+ ChannelID *int `json:"channel_id"`
+ Limit int `json:"limit"`
+ Offset int `json:"offset"`
+}
+
+type ChatSearchResult struct {
+ ID int `db:"id" json:"id"`
+ ChannelID int `db:"channel_id" json:"channel_id"`
+ ChannelName string `db:"channel_name" json:"channel_name"`
+ ChannelSlug string `db:"channel_slug" json:"channel_slug"`
+ ChannelType string `db:"channel_type" json:"channel_type"`
+ AuthorType string `db:"author_type" json:"author_type"`
+ SenderDisplayName string `db:"sender_display_name" json:"sender_display_name"`
+ Message string `db:"message" json:"message"`
+ Edited bool `db:"edited" json:"edited"`
+ Status string `db:"status" json:"status"`
+ CreatedAt time.Time `db:"created_at" json:"created_at"`
+ Rank float64 `db:"rank" json:"rank"`
+}
+
+type ChatRequestActionInput struct {
+ Input ChatRequestAction `json:"input" binding:"required"`
+}
+
+type ChatRequestAction struct {
+ RequestID int `json:"request_id" binding:"required"`
+}
+
+func CreateChatChannelWebhook(c *gin.Context) {
+ var input CreateChatChannelInput
+ if !bindChatInput(c, &input) {
+ return
+ }
+ operatorOperation, ok := chatOperatorOperation(c)
+ if !ok {
+ return
+ }
+ channelType := strings.ToLower(strings.TrimSpace(input.Input.ChannelType))
+ if channelType == "" {
+ channelType = databaseStructs.ChatChannelTypeStandard
+ }
+ if channelType != databaseStructs.ChatChannelTypeStandard && channelType != databaseStructs.ChatChannelTypeAI {
+ chatRespondError(c, "unknown chat channel type")
+ return
+ }
+ if !chatRequireScope(c, chatScopeForChannelType(channelType, true)) {
+ return
+ }
+ name := strings.TrimSpace(input.Input.Name)
+ if name == "" {
+ chatRespondError(c, "channel name is required")
+ return
+ }
+ slug, err := uniqueChatSlug(operatorOperation.CurrentOperation.ID, slugifyChatChannelName(name), 0)
+ if err != nil {
+ logging.LogError(err, "Failed to generate unique chat slug")
+ chatRespondError(c, err.Error())
+ return
+ }
+
+ var chatContainerID interface{}
+ if channelType == databaseStructs.ChatChannelTypeAI {
+ if input.Input.ChatContainerID == nil || *input.Input.ChatContainerID <= 0 {
+ chatRespondError(c, "AI chat channels require a chat_container_id")
+ return
+ }
+ if _, err = getChatContainer(*input.Input.ChatContainerID); err != nil {
+ logging.LogError(err, "Failed to find chat container")
+ chatRespondError(c, "failed to find a chat container with that id")
+ return
+ }
+ chatContainerID = *input.Input.ChatContainerID
+ }
+ var lockedBy interface{}
+ var lockedAt interface{}
+ if channelType == databaseStructs.ChatChannelTypeAI && input.Input.Locked {
+ lockedBy = operatorOperation.CurrentOperator.ID
+ lockedAt = time.Now().UTC()
+ }
+ metadata := chatJSONText(input.Input.AIMetadata)
+ var channelID int
+ err = database.DB.Get(&channelID, `INSERT INTO chat_channel
+ (operation_id, name, slug, description, channel_type, created_by, locked, locked_by, locked_at,
+ chat_container_id, chat_model, ai_metadata)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12::jsonb)
+ RETURNING id`,
+ operatorOperation.CurrentOperation.ID,
+ name,
+ slug,
+ input.Input.Description,
+ channelType,
+ operatorOperation.CurrentOperator.ID,
+ channelType == databaseStructs.ChatChannelTypeAI && input.Input.Locked,
+ lockedBy,
+ lockedAt,
+ chatContainerID,
+ input.Input.ChatModel,
+ metadata.String(),
+ )
+ if err != nil {
+ logging.LogError(err, "Failed to create chat channel")
+ chatRespondError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channelID, ChannelID: channelID})
+}
+
+func UpdateChatChannelWebhook(c *gin.Context) {
+ var input UpdateChatChannelInput
+ if !bindChatInput(c, &input) {
+ return
+ }
+ operatorOperation, ok := chatOperatorOperation(c)
+ if !ok {
+ return
+ }
+ channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to get chat channel")
+ chatRespondError(c, "failed to find chat channel")
+ return
+ }
+ if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
+ return
+ }
+ if !chatCanManageChannel(operatorOperation, channel) {
+ chatRespondError(c, "only the channel creator or an operation admin can update this channel")
+ return
+ }
+
+ name := channel.Name
+ slug := channel.Slug
+ if input.Input.Name != nil {
+ name = strings.TrimSpace(*input.Input.Name)
+ if name == "" {
+ chatRespondError(c, "channel name is required")
+ return
+ }
+ if chatIsGeneralChannel(channel) {
+ if name != channel.Name {
+ chatRespondError(c, "the general channel name cannot be changed")
+ return
+ }
+ } else {
+ slug, err = uniqueChatSlug(operatorOperation.CurrentOperation.ID, slugifyChatChannelName(name), channel.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to generate unique chat slug")
+ chatRespondError(c, err.Error())
+ return
+ }
+ }
+ }
+ description := channel.Description
+ if input.Input.Description != nil {
+ description = *input.Input.Description
+ }
+ archived := channel.Archived
+ var archivedBy interface{}
+ var archivedAt interface{}
+ if channel.ArchivedBy.Valid {
+ archivedBy = channel.ArchivedBy.Int64
+ }
+ if channel.ArchivedAt.Valid {
+ archivedAt = channel.ArchivedAt.Time
+ }
+ if input.Input.Archived != nil {
+ archived = *input.Input.Archived
+ if chatIsGeneralChannel(channel) && archived {
+ chatRespondError(c, "the general channel cannot be archived")
+ return
+ }
+ if archived {
+ archivedBy = operatorOperation.CurrentOperator.ID
+ archivedAt = time.Now().UTC()
+ } else {
+ archivedBy = nil
+ archivedAt = nil
+ }
+ }
+ locked := channel.Locked
+ var lockedBy interface{}
+ var lockedAt interface{}
+ if channel.LockedBy.Valid {
+ lockedBy = channel.LockedBy.Int64
+ }
+ if channel.LockedAt.Valid {
+ lockedAt = channel.LockedAt.Time
+ }
+ if input.Input.Locked != nil {
+ if channel.ChannelType != databaseStructs.ChatChannelTypeAI {
+ chatRespondError(c, "only AI chat channels can be locked")
+ return
+ }
+ locked = *input.Input.Locked
+ if locked {
+ lockedBy = operatorOperation.CurrentOperator.ID
+ lockedAt = time.Now().UTC()
+ } else {
+ if !chatIsModerator(operatorOperation) &&
+ (!channel.LockedBy.Valid || int(channel.LockedBy.Int64) != operatorOperation.CurrentOperator.ID) {
+ chatRespondError(c, "only the lock owner or an operation admin can unlock this AI chat")
+ return
+ }
+ lockedBy = nil
+ lockedAt = nil
+ }
+ }
+ chatModel := channel.ChatModel
+ if input.Input.ChatModel != nil {
+ chatModel = *input.Input.ChatModel
+ }
+ metadata := channel.AIMetadata
+ if input.Input.AIMetadata != nil {
+ metadata = chatJSONText(input.Input.AIMetadata)
+ }
+ _, err = database.DB.Exec(`UPDATE chat_channel
+ SET name=$3, slug=$4, description=$5, archived=$6, archived_by=$7, archived_at=$8,
+ locked=$9, locked_by=$10, locked_at=$11, chat_model=$12, ai_metadata=$13::jsonb
+ WHERE id=$1 AND operation_id=$2`,
+ channel.ID,
+ operatorOperation.CurrentOperation.ID,
+ name,
+ slug,
+ description,
+ archived,
+ archivedBy,
+ archivedAt,
+ locked,
+ lockedBy,
+ lockedAt,
+ chatModel,
+ metadata.String(),
+ )
+ if err != nil {
+ logging.LogError(err, "Failed to update chat channel")
+ chatRespondError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: channel.ID, ChannelID: channel.ID})
+}
+
+func CreateChatMessageWebhook(c *gin.Context) {
+ var input CreateChatMessageInput
+ if !bindChatInput(c, &input) {
+ return
+ }
+ operatorOperation, ok := chatOperatorOperation(c)
+ if !ok {
+ return
+ }
+ message := strings.TrimSpace(input.Input.Message)
+ if message == "" {
+ chatRespondError(c, "message is required")
+ return
+ }
+ channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to get chat channel for message")
+ chatRespondError(c, "failed to find chat channel")
+ return
+ }
+ if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
+ return
+ }
+ if input.Input.AllOperations {
+ if !input.Input.SystemMessage {
+ chatRespondError(c, "sending to all operations requires a system message")
+ return
+ }
+ if !operatorOperation.CurrentOperator.Admin {
+ chatRespondError(c, "only a Mythic admin can create system messages in all operations")
+ return
+ }
+ messageID, _, err := insertSystemChatMessageAllOperations(operatorOperation, message, authentication.RabbitMQAuthContextFromGin(c))
+ if err != nil {
+ logging.LogError(err, "Failed to create system chat messages in all operations")
+ chatRespondError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
+ return
+ }
+ if channel.Archived {
+ chatRespondError(c, "cannot post to an archived chat channel")
+ return
+ }
+ if input.Input.SystemMessage {
+ if !chatCanCreateSystemMessage(operatorOperation) {
+ chatRespondError(c, "only an admin can create system messages")
+ return
+ }
+ messageID, err := insertSystemChatMessage(operatorOperation, channel, message, authentication.RabbitMQAuthContextFromGin(c))
+ if err != nil {
+ logging.LogError(err, "Failed to create system chat message")
+ chatRespondError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
+ return
+ }
+ if channel.ChannelType == databaseStructs.ChatChannelTypeAI {
+ if !chatCanPostToAIChannel(operatorOperation, channel) {
+ chatRespondError(c, "this AI chat is locked to another operator")
+ return
+ }
+ createAIChatMessage(c, operatorOperation, channel, message, nil)
+ return
+ }
+ messageID, err := insertOperatorChatMessage(operatorOperation, channel, message, authentication.RabbitMQAuthContextFromGin(c))
+ if err != nil {
+ logging.LogError(err, "Failed to create chat message")
+ chatRespondError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: messageID, MessageID: messageID, ChannelID: channel.ID})
+}
+
+func EditChatMessageWebhook(c *gin.Context) {
+ var input EditChatMessageInput
+ if !bindChatInput(c, &input) {
+ return
+ }
+ operatorOperation, ok := chatOperatorOperation(c)
+ if !ok {
+ return
+ }
+ message := strings.TrimSpace(input.Input.Message)
+ if message == "" {
+ chatRespondError(c, "message is required")
+ return
+ }
+ chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to get chat message for edit")
+ chatRespondError(c, "failed to find chat message")
+ return
+ }
+ if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
+ return
+ }
+ if chatMessage.Deleted {
+ chatRespondError(c, "cannot edit a deleted chat message")
+ return
+ }
+ if chatMessage.AuthorType != databaseStructs.ChatMessageAuthorOperator ||
+ !chatMessage.OperatorID.Valid ||
+ int(chatMessage.OperatorID.Int64) != operatorOperation.CurrentOperator.ID {
+ chatRespondError(c, "only the message author can edit this message")
+ return
+ }
+ _, err = database.DB.Exec(`UPDATE chat_message
+ SET message=$3, edited=true, edited_at=now()
+ WHERE id=$1 AND operation_id=$2`, chatMessage.ID, operatorOperation.CurrentOperation.ID, message)
+ if err != nil {
+ logging.LogError(err, "Failed to edit chat message")
+ chatRespondError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID})
+}
+
+func DeleteChatMessageWebhook(c *gin.Context) {
+ var input DeleteChatMessageInput
+ if !bindChatInput(c, &input) {
+ return
+ }
+ operatorOperation, ok := chatOperatorOperation(c)
+ if !ok {
+ return
+ }
+ chatMessage, channel, err := getChatMessageAndChannel(input.Input.MessageID, operatorOperation.CurrentOperation.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to get chat message for delete")
+ chatRespondError(c, "failed to find chat message")
+ return
+ }
+ if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
+ return
+ }
+ if !chatIsModerator(operatorOperation) &&
+ (!chatMessage.OperatorID.Valid || int(chatMessage.OperatorID.Int64) != operatorOperation.CurrentOperator.ID) {
+ chatRespondError(c, "only the message author or an operation admin can delete this message")
+ return
+ }
+ _, err = database.DB.Exec(`UPDATE chat_message
+ SET message='Message Deleted',
+ deleted=true,
+ deleted_by=$3,
+ deleted_at=now(),
+ status='complete'
+ WHERE id=$1 AND operation_id=$2`, chatMessage.ID, operatorOperation.CurrentOperation.ID, operatorOperation.CurrentOperator.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to delete chat message")
+ chatRespondError(c, err.Error())
+ return
+ }
+ _, _ = database.DB.Exec(`UPDATE chat_request
+ SET status='cancelled', cancelled_at=now(), error='Chat message was deleted'
+ WHERE operation_id=$1 AND (request_message_id=$2 OR response_message_id=$2) AND status IN ('pending', 'streaming')`,
+ operatorOperation.CurrentOperation.ID, chatMessage.ID)
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ID: chatMessage.ID, MessageID: chatMessage.ID, ChannelID: channel.ID})
+}
+
+func MarkChatReadWebhook(c *gin.Context) {
+ var input MarkChatReadInput
+ if !bindChatInput(c, &input) {
+ return
+ }
+ operatorOperation, ok := chatOperatorOperation(c)
+ if !ok {
+ return
+ }
+ channel, err := getChatChannel(input.Input.ChannelID, operatorOperation.CurrentOperation.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to get chat channel for read marker")
+ chatRespondError(c, "failed to find chat channel")
+ return
+ }
+ if !chatRequireScope(c, chatScopeForChannel(channel, false)) {
+ return
+ }
+ var lastReadID interface{}
+ if input.Input.LastReadMessageID != nil {
+ lastReadID = *input.Input.LastReadMessageID
+ } else {
+ var maxID sql.NullInt64
+ if err = database.DB.Get(&maxID, `SELECT max(id) FROM chat_message
+ WHERE operation_id=$1 AND channel_id=$2`, operatorOperation.CurrentOperation.ID, channel.ID); err != nil {
+ logging.LogError(err, "Failed to get max chat message id")
+ chatRespondError(c, err.Error())
+ return
+ }
+ if maxID.Valid {
+ lastReadID = maxID.Int64
+ }
+ }
+ _, err = database.DB.Exec(`INSERT INTO chat_read_state
+ (operation_id, channel_id, operator_id, last_read_message_id)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (operator_id, channel_id) DO UPDATE
+ SET last_read_message_id=excluded.last_read_message_id`,
+ operatorOperation.CurrentOperation.ID, channel.ID, operatorOperation.CurrentOperator.ID, lastReadID)
+ if err != nil {
+ logging.LogError(err, "Failed to mark chat channel read")
+ chatRespondError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", ChannelID: channel.ID})
+}
+
+func ChatSearchWebhook(c *gin.Context) {
+ var input ChatSearchInput
+ if !bindChatInput(c, &input) {
+ return
+ }
+ operatorOperation, ok := chatOperatorOperation(c)
+ if !ok {
+ return
+ }
+ query := strings.TrimSpace(input.Input.Query)
+ if query == "" {
+ chatRespondError(c, "query is required")
+ return
+ }
+ allowedTypes := chatSearchAllowedChannelTypes(c)
+ if len(allowedTypes) == 0 {
+ chatRespondError(c, "missing required chat read scope")
+ return
+ }
+ limit := input.Input.Limit
+ if limit <= 0 || limit > 100 {
+ limit = 50
+ }
+ offset := input.Input.Offset
+ if offset < 0 {
+ offset = 0
+ }
+ var channelID interface{}
+ if input.Input.ChannelID != nil {
+ channelID = *input.Input.ChannelID
+ }
+ results := []ChatSearchResult{}
+ err := database.DB.Select(&results, `SELECT
+ chat_message.id,
+ chat_message.channel_id,
+ chat_channel.name "channel_name",
+ chat_channel.slug "channel_slug",
+ chat_channel.channel_type,
+ chat_message.author_type,
+ chat_message.sender_display_name,
+ chat_message.message,
+ chat_message.edited,
+ chat_message.status,
+ chat_message.created_at,
+ ts_rank(chat_message.search_vector, plainto_tsquery('simple', $3)) "rank"
+ FROM chat_message
+ JOIN chat_channel ON chat_message.channel_id = chat_channel.id
+ WHERE chat_message.operation_id=$1
+ AND chat_message.deleted=false
+ AND chat_channel.channel_type = ANY($2)
+ AND chat_message.search_vector @@ plainto_tsquery('simple', $3)
+ AND ($4::integer IS NULL OR chat_message.channel_id=$4)
+ ORDER BY rank DESC, chat_message.id DESC
+ LIMIT $5 OFFSET $6`,
+ operatorOperation.CurrentOperation.ID,
+ pq.Array(allowedTypes),
+ query,
+ channelID,
+ limit,
+ offset,
+ )
+ if err != nil {
+ logging.LogError(err, "Failed to search chat")
+ chatRespondError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", Results: results})
+}
+
+func CancelChatRequestWebhook(c *gin.Context) {
+ var input ChatRequestActionInput
+ if !bindChatInput(c, &input) {
+ return
+ }
+ operatorOperation, ok := chatOperatorOperation(c)
+ if !ok {
+ return
+ }
+ request, channel, err := getChatRequestAndChannel(input.Input.RequestID, operatorOperation.CurrentOperation.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to find chat request for cancel")
+ chatRespondError(c, "failed to find chat request")
+ return
+ }
+ if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
+ return
+ }
+ if !chatIsModerator(operatorOperation) && request.CreatedBy != operatorOperation.CurrentOperator.ID {
+ chatRespondError(c, "only the request creator or an operation admin can cancel this request")
+ return
+ }
+ _, err = database.DB.Exec(`UPDATE chat_request
+ SET status='cancelled', cancelled_at=now(), error='Cancelled by operator'
+ WHERE id=$1 AND operation_id=$2 AND status IN ('pending', 'streaming')`,
+ request.ID, operatorOperation.CurrentOperation.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to cancel chat request")
+ chatRespondError(c, err.Error())
+ return
+ }
+ _, _ = database.DB.Exec(`UPDATE chat_message
+ SET status='cancelled'
+ WHERE id=$1 AND operation_id=$2 AND status IN ('pending', 'streaming')`,
+ request.ResponseMessageID, operatorOperation.CurrentOperation.ID)
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "success", RequestID: request.ID, ResponseMessageID: request.ResponseMessageID, ChannelID: channel.ID})
+}
+
+func RetryChatRequestWebhook(c *gin.Context) {
+ var input ChatRequestActionInput
+ if !bindChatInput(c, &input) {
+ return
+ }
+ operatorOperation, ok := chatOperatorOperation(c)
+ if !ok {
+ return
+ }
+ request, channel, err := getChatRequestAndChannel(input.Input.RequestID, operatorOperation.CurrentOperation.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to find chat request for retry")
+ chatRespondError(c, "failed to find chat request")
+ return
+ }
+ if !chatRequireScope(c, chatScopeForChannel(channel, true)) {
+ return
+ }
+ if channel.Archived {
+ chatRespondError(c, "cannot retry in an archived chat channel")
+ return
+ }
+ if !chatCanPostToAIChannel(operatorOperation, channel) {
+ chatRespondError(c, "this AI chat is locked to another operator")
+ return
+ }
+ var prompt string
+ if err = database.DB.Get(&prompt, `SELECT message FROM chat_message
+ WHERE id=$1 AND operation_id=$2 AND deleted=false`,
+ request.RequestMessageID, operatorOperation.CurrentOperation.ID); err != nil {
+ logging.LogError(err, "Failed to get original prompt for retry")
+ chatRespondError(c, "failed to find original prompt")
+ return
+ }
+ createAIChatMessage(c, operatorOperation, channel, prompt, &request.ID)
+}
+
+func createAIChatMessage(c *gin.Context, operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, retryOfID *int) {
+ container, err := getChatContainer(int(channel.ChatContainerID.Int64))
+ if err != nil {
+ logging.LogError(err, "Failed to find AI chat container")
+ chatRespondError(c, "failed to find AI chat container")
+ return
+ }
+ if !container.ContainerRunning {
+ chatRespondError(c, fmt.Sprintf("chat container %s is not running", container.Name))
+ return
+ }
+ activeRequestID, err := getActiveAIChatRequest(operatorOperation.CurrentOperation.ID, channel.ID)
+ if err != nil {
+ logging.LogError(err, "Failed to check active AI chat request")
+ chatRespondError(c, err.Error())
+ return
+ }
+ if activeRequestID > 0 {
+ chatRespondError(c, fmt.Sprintf("AI chat request %d is still in progress; wait for it to finish or cancel it before sending another prompt", activeRequestID))
+ return
+ }
+ authContext := authentication.RabbitMQAuthContextFromGin(c)
+ requestMessageID, err := insertOperatorChatMessage(operatorOperation, channel, message, authContext)
+ if err != nil {
+ logging.LogError(err, "Failed to create AI prompt message")
+ chatRespondError(c, err.Error())
+ return
+ }
+ var responseMessageID int
+ if err = database.DB.Get(&responseMessageID, `INSERT INTO chat_message
+ (operation_id, channel_id, author_type, chat_container_id, sender_display_name, status, metadata)
+ VALUES ($1, $2, 'ai', $3, $4, 'pending', $5::jsonb)
+ RETURNING id`,
+ operatorOperation.CurrentOperation.ID,
+ channel.ID,
+ container.ID,
+ container.Name,
+ rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{"model": channel.ChatModel}).String(),
+ ); err != nil {
+ logging.LogError(err, "Failed to create AI response placeholder")
+ chatRespondError(c, err.Error())
+ return
+ }
+ var retryOf interface{}
+ if retryOfID != nil {
+ retryOf = *retryOfID
+ }
+ var requestID int
+ if err = database.DB.Get(&requestID, `INSERT INTO chat_request
+ (operation_id, channel_id, request_message_id, response_message_id, chat_container_id,
+ model, status, context_snapshot, retry_of_id, created_by)
+ VALUES ($1, $2, $3, $4, $5, $6, 'pending', $7::jsonb, $8, $9)
+ RETURNING id`,
+ operatorOperation.CurrentOperation.ID,
+ channel.ID,
+ requestMessageID,
+ responseMessageID,
+ container.ID,
+ channel.ChatModel,
+ rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{}).String(),
+ retryOf,
+ operatorOperation.CurrentOperator.ID,
+ ); err != nil {
+ logging.LogError(err, "Failed to create AI chat request")
+ chatRespondError(c, err.Error())
+ return
+ }
+ contextMessages, err := getChatContextMessages(operatorOperation.CurrentOperation.ID, channel.ID, requestMessageID)
+ if err != nil {
+ logging.LogError(err, "Failed to fetch AI chat context")
+ markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
+ chatRespondError(c, err.Error())
+ return
+ }
+ contextIDs := make([]int, len(contextMessages))
+ for i := range contextMessages {
+ contextIDs[i] = contextMessages[i].ID
+ }
+ contextSnapshot := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
+ "context_message_ids": contextIDs,
+ "request_message_id": requestMessageID,
+ "response_message_id": responseMessageID,
+ "retry_of_id": retryOf,
+ "context_message_limit": chatContextMessageLimit,
+ })
+ _, _ = database.DB.Exec(`UPDATE chat_request
+ SET context_snapshot=$3::jsonb
+ WHERE id=$1 AND operation_id=$2`, requestID, operatorOperation.CurrentOperation.ID, contextSnapshot.String())
+
+ err = rabbitmq.RabbitMQConnection.SendChatContainerRequest(container.Name, rabbitmq.ChatContainerRequestMessage{
+ OperationID: operatorOperation.CurrentOperation.ID,
+ ChannelID: channel.ID,
+ ChannelName: channel.Name,
+ ChannelSlug: channel.Slug,
+ RequestID: requestID,
+ RequestMessageID: requestMessageID,
+ ResponseMessageID: responseMessageID,
+ Model: channel.ChatModel,
+ Prompt: message,
+ Context: contextMessages,
+ Secrets: rabbitmq.GetSecrets(operatorOperation.CurrentOperator.ID, 0),
+ }, authContext)
+ if err != nil {
+ markChatRequestFailed(requestID, responseMessageID, operatorOperation.CurrentOperation.ID, err.Error())
+ chatRespondError(c, err.Error())
+ return
+ }
+ c.JSON(http.StatusOK, ChatActionResponse{
+ Status: "success",
+ ID: requestMessageID,
+ MessageID: requestMessageID,
+ ChannelID: channel.ID,
+ RequestID: requestID,
+ ResponseMessageID: responseMessageID,
+ })
+}
+
+func bindChatInput(c *gin.Context, input interface{}) bool {
+ if err := c.ShouldBindJSON(input); err != nil {
+ logging.LogError(err, "Failed to get JSON parameters for chat webhook")
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "error", Error: err.Error()})
+ return false
+ }
+ return true
+}
+
+func chatOperatorOperation(c *gin.Context) (*databaseStructs.Operatoroperation, bool) {
+ ginOperatorOperation, ok := c.Get(authentication.ContextKeyOperatorOperationStruct)
+ if !ok {
+ chatRespondError(c, "failed to get current operation")
+ return nil, false
+ }
+ operatorOperation := ginOperatorOperation.(*databaseStructs.Operatoroperation)
+ return operatorOperation, true
+}
+
+func chatRespondError(c *gin.Context, err string) {
+ c.JSON(http.StatusOK, ChatActionResponse{Status: "error", Error: err})
+}
+
+func chatRequireScope(c *gin.Context, requiredScope string) bool {
+ claims, err := authentication.GetClaims(c)
+ if err != nil {
+ logging.LogError(err, "Failed to get claims for chat scope check")
+ chatRespondError(c, "failed to get authentication claims")
+ return false
+ }
+ if !mythicjwt.AllowsScope(claims.Scopes, requiredScope) {
+ chatRespondError(c, fmt.Sprintf("missing required scope: %s", requiredScope))
+ return false
+ }
+ return true
+}
+
+func chatSearchAllowedChannelTypes(c *gin.Context) []string {
+ claims, err := authentication.GetClaims(c)
+ if err != nil {
+ logging.LogError(err, "Failed to get claims for chat search scope check")
+ return []string{}
+ }
+ channelTypes := []string{}
+ if mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_CHAT_READ) {
+ channelTypes = append(channelTypes, databaseStructs.ChatChannelTypeStandard)
+ }
+ if mythicjwt.AllowsScope(claims.Scopes, mythicjwt.SCOPE_CHAT_AI_READ) {
+ channelTypes = append(channelTypes, databaseStructs.ChatChannelTypeAI)
+ }
+ return channelTypes
+}
+
+func chatScopeForChannelType(channelType string, write bool) string {
+ if channelType == databaseStructs.ChatChannelTypeAI {
+ if write {
+ return mythicjwt.SCOPE_CHAT_AI_WRITE
+ }
+ return mythicjwt.SCOPE_CHAT_AI_READ
+ }
+ if write {
+ return mythicjwt.SCOPE_CHAT_WRITE
+ }
+ return mythicjwt.SCOPE_CHAT_READ
+}
+
+func chatScopeForChannel(channel databaseStructs.ChatChannel, write bool) string {
+ return chatScopeForChannelType(channel.ChannelType, write)
+}
+
+func chatIsModerator(operatorOperation *databaseStructs.Operatoroperation) bool {
+ return operatorOperation.CurrentOperator.Admin ||
+ operatorOperation.ViewMode == database.OPERATOR_OPERATION_VIEW_MODE_LEAD
+}
+
+func chatCanCreateSystemMessage(operatorOperation *databaseStructs.Operatoroperation) bool {
+ return chatIsModerator(operatorOperation)
+}
+
+func chatIsGeneralChannel(channel databaseStructs.ChatChannel) bool {
+ return channel.ChannelType == databaseStructs.ChatChannelTypeStandard &&
+ strings.EqualFold(channel.Slug, chatGeneralChannelName)
+}
+
+func chatCanManageChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
+ return chatIsModerator(operatorOperation) || channel.CreatedBy == operatorOperation.CurrentOperator.ID
+}
+
+func chatCanPostToAIChannel(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel) bool {
+ if channel.ChannelType != databaseStructs.ChatChannelTypeAI {
+ return true
+ }
+ if !channel.Locked {
+ return true
+ }
+ return chatIsModerator(operatorOperation) ||
+ (channel.LockedBy.Valid && int(channel.LockedBy.Int64) == operatorOperation.CurrentOperator.ID)
+}
+
+func slugifyChatChannelName(name string) string {
+ slug := strings.ToLower(strings.TrimSpace(name))
+ slug = chatSlugInvalidCharacters.ReplaceAllString(slug, "-")
+ slug = strings.Trim(slug, "-")
+ if slug == "" {
+ return "channel"
+ }
+ return slug
+}
+
+func uniqueChatSlug(operationID int, baseSlug string, excludeChannelID int) (string, error) {
+ for i := 0; i < 1000; i++ {
+ candidate := baseSlug
+ if i > 0 {
+ candidate = fmt.Sprintf("%s-%d", baseSlug, i+1)
+ }
+ var existingID int
+ err := database.DB.Get(&existingID, `SELECT id FROM chat_channel
+ WHERE operation_id=$1 AND lower(slug)=lower($2) AND id <> $3`,
+ operationID, candidate, excludeChannelID)
+ if err == sql.ErrNoRows {
+ return candidate, nil
+ }
+ if err != nil {
+ return "", err
+ }
+ }
+ return "", fmt.Errorf("failed to find an available slug for %s", baseSlug)
+}
+
+func chatJSONText(input interface{}) databaseStructs.MythicJSONText {
+ if input == nil {
+ return rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{})
+ }
+ if inputString, ok := input.(string); ok {
+ var raw json.RawMessage
+ if err := json.Unmarshal([]byte(inputString), &raw); err == nil {
+ return rabbitmq.GetMythicJSONTextFromStruct(raw)
+ }
+ }
+ return rabbitmq.GetMythicJSONTextFromStruct(input)
+}
+
+func getChatContainer(containerID int) (databaseStructs.ConsumingContainer, error) {
+ container := databaseStructs.ConsumingContainer{}
+ err := database.DB.Get(&container, `SELECT *
+ FROM consuming_container
+ WHERE id=$1 AND type=$2 AND deleted=false`, containerID, string(rabbitmq.CONSUMING_SERVICES_TYPE_CHAT))
+ return container, err
+}
+
+func getChatChannel(channelID int, operationID int) (databaseStructs.ChatChannel, error) {
+ channel := databaseStructs.ChatChannel{}
+ err := database.DB.Get(&channel, `SELECT *
+ FROM chat_channel
+ WHERE id=$1 AND operation_id=$2`, channelID, operationID)
+ return channel, err
+}
+
+func getChatMessageAndChannel(messageID int, operationID int) (databaseStructs.ChatMessage, databaseStructs.ChatChannel, error) {
+ chatMessage := databaseStructs.ChatMessage{}
+ if err := database.DB.Get(&chatMessage, `SELECT
+ id,
+ operation_id,
+ channel_id,
+ operator_id,
+ apitokens_id,
+ author_type,
+ chat_container_id,
+ sender_display_name,
+ message,
+ edited,
+ edited_at,
+ deleted,
+ deleted_by,
+ deleted_at,
+ status,
+ metadata,
+ created_at,
+ updated_at
+ FROM chat_message
+ WHERE id=$1 AND operation_id=$2`, messageID, operationID); err != nil {
+ return chatMessage, databaseStructs.ChatChannel{}, err
+ }
+ channel, err := getChatChannel(chatMessage.ChannelID, operationID)
+ return chatMessage, channel, err
+}
+
+func getChatRequestAndChannel(requestID int, operationID int) (databaseStructs.ChatRequest, databaseStructs.ChatChannel, error) {
+ request := databaseStructs.ChatRequest{}
+ if err := database.DB.Get(&request, `SELECT *
+ FROM chat_request
+ WHERE id=$1 AND operation_id=$2`, requestID, operationID); err != nil {
+ return request, databaseStructs.ChatChannel{}, err
+ }
+ channel, err := getChatChannel(request.ChannelID, operationID)
+ return request, channel, err
+}
+
+func insertOperatorChatMessage(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, authContext rabbitmq.RabbitMQAuthContext) (int, error) {
+ var apiTokenID interface{}
+ if authContext.APITokensID > 0 {
+ apiTokenID = authContext.APITokensID
+ }
+ var messageID int
+ err := database.DB.Get(&messageID, `INSERT INTO chat_message
+ (operation_id, channel_id, operator_id, apitokens_id, author_type, sender_display_name, message, status)
+ VALUES ($1, $2, $3, $4, 'operator', $5, $6, 'complete')
+ RETURNING id`,
+ operatorOperation.CurrentOperation.ID,
+ channel.ID,
+ operatorOperation.CurrentOperator.ID,
+ apiTokenID,
+ operatorOperation.CurrentOperator.Username,
+ message,
+ )
+ return messageID, err
+}
+
+func insertSystemChatMessage(operatorOperation *databaseStructs.Operatoroperation, channel databaseStructs.ChatChannel, message string, authContext rabbitmq.RabbitMQAuthContext) (int, error) {
+ var apiTokenID interface{}
+ if authContext.APITokensID > 0 {
+ apiTokenID = authContext.APITokensID
+ }
+ metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
+ "submitted_by_operator_id": operatorOperation.CurrentOperator.ID,
+ "submitted_by": operatorOperation.CurrentOperator.Username,
+ })
+ var messageID int
+ err := database.DB.Get(&messageID, `INSERT INTO chat_message
+ (operation_id, channel_id, apitokens_id, author_type, sender_display_name, message, status, metadata)
+ VALUES ($1, $2, $3, 'system', 'System', $4, 'complete', $5::jsonb)
+ RETURNING id`,
+ operatorOperation.CurrentOperation.ID,
+ channel.ID,
+ apiTokenID,
+ message,
+ metadata.String(),
+ )
+ return messageID, err
+}
+
+func insertSystemChatMessageAllOperations(operatorOperation *databaseStructs.Operatoroperation, message string, authContext rabbitmq.RabbitMQAuthContext) (int, int, error) {
+ if err := ensureGeneralChatChannels(); err != nil {
+ return 0, 0, err
+ }
+ var apiTokenID interface{}
+ if authContext.APITokensID > 0 {
+ apiTokenID = authContext.APITokensID
+ }
+ metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
+ "submitted_by_operator_id": operatorOperation.CurrentOperator.ID,
+ "submitted_by": operatorOperation.CurrentOperator.Username,
+ "all_operations": true,
+ })
+ messageIDs := []int{}
+ err := database.DB.Select(&messageIDs, `INSERT INTO chat_message
+ (operation_id, channel_id, apitokens_id, author_type, sender_display_name, message, status, metadata)
+ SELECT chat_channel.operation_id, chat_channel.id, $1, 'system', 'System', $2, 'complete', $3::jsonb
+ FROM chat_channel
+ JOIN operation ON operation.id=chat_channel.operation_id
+ WHERE operation.deleted=false
+ AND chat_channel.channel_type=$4
+ AND lower(chat_channel.slug)=$5
+ RETURNING id`,
+ apiTokenID,
+ message,
+ metadata.String(),
+ databaseStructs.ChatChannelTypeStandard,
+ chatGeneralChannelName,
+ )
+ if err != nil {
+ return 0, 0, err
+ }
+ if len(messageIDs) == 0 {
+ return 0, 0, fmt.Errorf("failed to find general channels for any operations")
+ }
+ return messageIDs[0], len(messageIDs), nil
+}
+
+func ensureGeneralChatChannels() error {
+ _, err := database.DB.Exec(`INSERT INTO chat_channel
+ (operation_id, name, slug, description, channel_type, created_by)
+ SELECT id, $1, $1, 'Default operation chat channel', $2, admin_id
+ FROM operation
+ WHERE deleted=false
+ ON CONFLICT DO NOTHING`,
+ chatGeneralChannelName,
+ databaseStructs.ChatChannelTypeStandard,
+ )
+ if err != nil {
+ return err
+ }
+ _, err = database.DB.Exec(`UPDATE chat_channel
+ SET name=$1,
+ archived=false,
+ archived_by=NULL,
+ archived_at=NULL
+ WHERE channel_type=$2
+ AND lower(slug)=$1
+ AND (name <> $1 OR archived=true OR archived_by IS NOT NULL OR archived_at IS NOT NULL)`,
+ chatGeneralChannelName,
+ databaseStructs.ChatChannelTypeStandard,
+ )
+ return err
+}
+
+func getActiveAIChatRequest(operationID int, channelID int) (int, error) {
+ var requestID int
+ err := database.DB.Get(&requestID, `SELECT id
+ FROM chat_request
+ WHERE operation_id=$1
+ AND channel_id=$2
+ AND status IN ('pending', 'streaming')
+ ORDER BY id DESC
+ LIMIT 1`, operationID, channelID)
+ if err == sql.ErrNoRows {
+ return 0, nil
+ }
+ return requestID, err
+}
+
+func getChatContextMessages(operationID int, channelID int, lastMessageID int) ([]rabbitmq.ChatContainerContextMessage, error) {
+ contextMessages := []rabbitmq.ChatContainerContextMessage{}
+ if err := database.DB.Select(&contextMessages, `SELECT
+ id,
+ author_type,
+ sender_display_name,
+ message,
+ created_at
+ FROM chat_message
+ WHERE operation_id=$1
+ AND channel_id=$2
+ AND id <= $3
+ AND deleted=false
+ AND (author_type <> 'ai' OR status='complete')
+ ORDER BY id DESC
+ LIMIT $4`, operationID, channelID, lastMessageID, chatContextMessageLimit); err != nil {
+ return contextMessages, err
+ }
+ for i, j := 0, len(contextMessages)-1; i < j; i, j = i+1, j-1 {
+ contextMessages[i], contextMessages[j] = contextMessages[j], contextMessages[i]
+ }
+ return contextMessages, nil
+}
+
+func markChatRequestFailed(requestID int, responseMessageID int, operationID int, errorMessage string) {
+ metadata := rabbitmq.GetMythicJSONTextFromStruct(map[string]interface{}{
+ "error": errorMessage,
+ })
+ _, _ = database.DB.Exec(`UPDATE chat_message
+ SET status='error', metadata=metadata || $3::jsonb
+ WHERE id=$1 AND operation_id=$2`, responseMessageID, operationID, metadata.String())
+ _, _ = database.DB.Exec(`UPDATE chat_request
+ SET status='error', error=$3
+ WHERE id=$1 AND operation_id=$2`, requestID, operationID, errorMessage)
+}
diff --git a/mythic-docker/src/webserver/controllers/login.go b/mythic-docker/src/webserver/controllers/login.go
index 768be992..31ceff90 100644
--- a/mythic-docker/src/webserver/controllers/login.go
+++ b/mythic-docker/src/webserver/controllers/login.go
@@ -46,6 +46,8 @@ func Login(c *gin.Context) {
"username": currentOperation.CurrentOperator.Username,
"id": currentOperation.CurrentOperator.ID,
"user_id": currentOperation.CurrentOperator.ID,
+ "admin": currentOperation.CurrentOperator.Admin,
+ "view_mode": currentOperation.ViewMode,
"view_utc_time": currentOperation.CurrentOperator.ViewUtcTime,
"current_utc_time": time.Now().UTC(),
}
@@ -75,8 +77,12 @@ func GetMeWebhook(c *gin.Context) {
"current_operation_banner_color": currentOperation.CurrentOperation.BannerColor,
"current_operation_complete": currentOperation.CurrentOperation.Complete,
"current_operation_id": currentOperation.CurrentOperation.ID,
+ "username": currentOperation.CurrentOperator.Username,
"user_id": currentOperation.CurrentOperator.ID,
"id": currentOperation.CurrentOperator.ID,
+ "admin": currentOperation.CurrentOperator.Admin,
+ "view_mode": currentOperation.ViewMode,
+ "view_utc_time": currentOperation.CurrentOperator.ViewUtcTime,
"current_utc_time": time.Now().UTC(),
"scope_info": buildScopeIntrospectionResponse(c, currentClaimsOrNil(c)),
})
@@ -162,6 +168,8 @@ func RefreshJWT(c *gin.Context) {
"username": currentOperation.CurrentOperator.Username,
"id": currentOperation.CurrentOperator.ID,
"user_id": currentOperation.CurrentOperator.ID,
+ "admin": currentOperation.CurrentOperator.Admin,
+ "view_mode": currentOperation.ViewMode,
"view_utc_time": currentOperation.CurrentOperator.ViewUtcTime,
"current_utc_time": time.Now().UTC(),
}
diff --git a/mythic-docker/src/webserver/initialize.go b/mythic-docker/src/webserver/initialize.go
index 275f5555..050945fd 100644
--- a/mythic-docker/src/webserver/initialize.go
+++ b/mythic-docker/src/webserver/initialize.go
@@ -313,6 +313,10 @@ func setRoutes(r *gin.Engine) {
mythicjwt.SCOPE_FILE_READ,
}),
webcontroller.DownloadBulkFilesWebhook)
+ allOperationMembers.POST("chat_search_webhook",
+ webcontroller.ChatSearchWebhook)
+ allOperationMembers.POST("chat_mark_read_webhook",
+ webcontroller.MarkChatReadWebhook)
allOperationMembers.POST("preview_file_webhook",
authentication.TokenScopeMiddleware([]string{
mythicjwt.SCOPE_FILE_READ,
@@ -354,6 +358,21 @@ func setRoutes(r *gin.Engine) {
webcontroller.ConsumingServicesTestWebhook)
noSpectators.POST("consuming_services_test_log",
webcontroller.ConsumingServicesTestLog)
+ // chat
+ noSpectators.POST("chat_create_channel_webhook",
+ webcontroller.CreateChatChannelWebhook)
+ noSpectators.POST("chat_update_channel_webhook",
+ webcontroller.UpdateChatChannelWebhook)
+ noSpectators.POST("chat_create_message_webhook",
+ webcontroller.CreateChatMessageWebhook)
+ noSpectators.POST("chat_edit_message_webhook",
+ webcontroller.EditChatMessageWebhook)
+ noSpectators.POST("chat_delete_message_webhook",
+ webcontroller.DeleteChatMessageWebhook)
+ noSpectators.POST("chat_cancel_request_webhook",
+ webcontroller.CancelChatRequestWebhook)
+ noSpectators.POST("chat_retry_request_webhook",
+ webcontroller.RetryChatRequestWebhook)
// creating a payload
noSpectators.POST("createpayload_webhook",
authentication.TokenScopeMiddleware([]string{