discourse/plugins/chat/assets/javascripts/discourse/services/chat-api.js
Joffrey JAFFEUX 582de0ffe3
DEV: adds blocks support to chat messages (#29782)
Blocks allow BOTS to augment the capacities of a chat message. At the moment only one block is available: `actions`, accepting only one type of element: `button`.

<img width="708" alt="Screenshot 2024-11-15 at 19 14 02" src="https://github.com/user-attachments/assets/63f32a29-05b1-4f32-9edd-8d8e1007d705">

# Usage

```ruby
Chat::CreateMessage.call(
  params: {
    message: "Welcome!",
    chat_channel_id: 2,
    blocks: [
      {
         type: "actions",
         elements: [
           { value: "foo", type: "button", text: { text: "How can I install themes?", type: "plain_text" } }
         ]
      }
    ]
  },
  guardian: Discourse.system_user.guardian
)
```

# Documentation

## Blocks

### Actions

Holds interactive elements: button.

#### Fields

| Field | Type | Description | Required? |
|--------|--------|--------|--------|
| type | string | For an actions block, type is always `actions` | Yes |
| elements | array | An array of interactive elements, maximum 10 elements | Yes |
| block_id | string | An unique identifier for the block, will be generated if not specified. It has to be unique per message | No |

#### Example

```json
{
  "type": "actions",
  "block_id": "actions_1",
  "elements": [...]
}
```

## Elements

### Button

#### Fields

| Field | Type | Description | Required? |
|--------|--------|--------|--------|
| type | string | For a button, type is always `button` | Yes |
| text | object | A text object holding the type and text. Max 75 characters | Yes |
| value | string | The value returned after the interaction has been validated. Maximum length is 2000 characters | No |
| style | string | Can be `primary` ,  `success` or `danger` | No |
| action_id | string | An unique identifier for the action, will be generated if not specified. It has to be unique per message | No |

#### Example

```json
{
  "type": "actions",
  "block_id": "actions_1",
  "elements": [
    {
      "type": "button",
      "text": {
          "type": "plain_text",
          "text": "Ok"
      },
      "value": "ok",
      "action_id": "button_1"
    }
  ]
}
```

## Interactions

When a user interactions with a button the following flow will happen:

- We send an interaction request to the server
- Server checks if the user can make this interaction
- If the user can make this interaction, the server will:

  * `DiscourseEvent.trigger(:chat_message_interaction, interaction)`
  * return a JSON document
  
  ```json
  {
    "interaction": {
        "user": {
            "id": 1,
            "username": "j.jaffeux"
        },
        "channel": {
            "id": 1,
            "title": "Staff"
        },
        "message": {
            "id": 1,
            "text": "test",
            "user_id": -1
        },
        "action": {
            "text": {
                "text": "How to install themes?",
                "type": "plain_text"
            },
            "type": "button",
            "value": "click_me_123",
            "action_id": "bf4f30b9-de99-4959-b3f5-632a6a1add04"
        }
    }
  }
  ```
  * Fire a `appEvents.trigger("chat:message_interaction", interaction)`
2024-11-19 07:07:58 +01:00

642 lines
19 KiB
JavaScript

import Service, { service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership";
import Collection from "../lib/collection";
/**
* Chat API service. Provides methods to interact with the chat API.
*
* @module ChatApi
* @implements {@ember/service}
*/
export default class ChatApi extends Service {
@service chat;
@service chatChannelsManager;
channel(channelId) {
return this.#getRequest(`/channels/${channelId}`);
}
channelThreadMessages(channelId, threadId, params = {}) {
return this.#getRequest(
`/channels/${channelId}/threads/${threadId}/messages?${new URLSearchParams(
params
).toString()}`
);
}
channelMessages(channelId, params = {}) {
return this.#getRequest(
`/channels/${channelId}/messages?${new URLSearchParams(
params
).toString()}`
);
}
/**
* Flags a message in a channel.
* @param {number} channelId - The ID of the channel.
* @param {number} messageId - The ID of the message to flag.
* @param {object} params - Params of the flag.
* @param {integer} params.flag_type_id
* @param {string} [params.message]
* @param {boolean} [params.is_warning]
* @param {boolean} [params.queue_for_review]
* @param {boolean} [params.take_action]
* @returns {Promise}
*
* @example
*
* this.chatApi.flagMessage(5, 1);
*/
flagMessage(channelId, messageId, params = {}) {
return this.#postRequest(
`/channels/${channelId}/messages/${messageId}/flags`,
params
);
}
/**
* Get a thread in a channel by its ID.
* @param {number} channelId - The ID of the channel.
* @param {number} threadId - The ID of the thread.
* @returns {Promise}
*
* @example
*
* this.chatApi.thread(5, 1).then(thread => { ... })
*/
thread(channelId, threadId) {
return this.#getRequest(`/channels/${channelId}/threads/${threadId}`);
}
/**
* Loads all threads for a channel.
* For now we only get the 50 threads ordered
* by the last message sent by the user then the
* thread creation date, later we will paginate
* and add filters.
* @param {number} channelId - The ID of the channel.
* @returns {Promise}
*/
threads(channelId, handler) {
return new Collection(
`${this.#basePath}/channels/${channelId}/threads`,
handler
);
}
/**
* List all accessible category channels of the current user.
* @returns {Collection}
*
* @example
*
* this.chatApi.channels.then(channels => { ... })
*/
channels(params = {}) {
return new Collection(
`${this.#basePath}/channels`,
(response) => {
return response.channels.map((channel) =>
this.chatChannelsManager.store(channel)
);
},
params
);
}
/**
* Moves messages from one channel to another.
* @param {number} channelId - The ID of the original channel.
* @param {object} data - Params of the move.
* @param {Array.<number>} data.message_ids - IDs of the moved messages.
* @param {number} data.destination_channel_id - ID of the channel where the messages are moved to.
* @returns {Promise}
*
* @example
*
* this.chatApi
* .moveChannelMessages(1, {
* message_ids: [2, 3],
* destination_channel_id: 4,
* }).then(() => { ... })
*/
moveChannelMessages(channelId, data = {}) {
return this.#postRequest(`/channels/${channelId}/messages/moves`, {
move: data,
});
}
/**
* Destroys a channel.
* @param {number} channelId - The ID of the channel.
* @returns {Promise}
*
* @example
*
* this.chatApi.destroyChannel(1).then(() => { ... })
*/
destroyChannel(channelId) {
return this.#deleteRequest(`/channels/${channelId}`);
}
/**
* Creates a channel.
* @param {object} data - Params of the channel.
* @param {string} data.name - The name of the channel.
* @param {string} data.chatable_id - The category of the channel.
* @param {string} data.description - The description of the channel.
* @param {boolean} [data.auto_join_users] - Should users join this channel automatically.
* @returns {Promise}
*
* @example
*
* this.chatApi
* .createChannel({ name: "foo", chatable_id: 1, description "bar" })
* .then((channel) => { ... })
*/
createChannel(data = {}) {
return this.#postRequest("/channels", { channel: data }).then((response) =>
this.chatChannelsManager.store(response.channel)
);
}
/**
* Lists chat permissions for a category.
* @param {number} categoryId - ID of the category.
* @returns {Promise}
*/
categoryPermissions(categoryId) {
return this.#getRequest(`/category-chatables/${categoryId}/permissions`);
}
/**
* Sends a message.
* @param {number} channelId - ID of the channel.
* @param {object} data - Params of the message.
* @param {string} data.message - The raw content of the message in markdown.
* @param {string} data.cooked - The cooked content of the message.
* @param {number} [data.in_reply_to_id] - The ID of the replied-to message.
* @param {number} [data.staged_id] - The staged ID of the message before it was persisted.
* @param {number} [data.thread_id] - The ID of the thread where this message should be posted.
* @param {number} [data.topic_id] - The ID of the currently visible topic in drawer mode.
* @param {number} [data.post_ids] - The ID of the currently visible posts in drawer mode.
* @param {Array.<number>} [data.upload_ids] - Array of upload ids linked to the message.
* @returns {Promise}
*/
sendMessage(channelId, data = {}) {
return ajax(`/chat/${channelId}`, {
ignoreUnsent: false,
type: "POST",
data,
});
}
/**
* Stop streaming of a message
* @param {number} channelId - ID of the channel.
* @param {number} messageId - ID of the message.
* @returns {Promise}
*/
stopMessageStreaming(channelId, messageId) {
return this.#deleteRequest(
`/channels/${channelId}/messages/${messageId}/streaming`
);
}
/**
* Trashes (soft deletes) a chat message.
* @param {number} channelId - ID of the channel.
* @param {number} messageId - ID of the message.
* @returns {Promise}
*/
trashMessage(channelId, messageId) {
return this.#deleteRequest(`/channels/${channelId}/messages/${messageId}`);
}
/**
* Trashes (soft deletes) multiple chat messages.
* @param {number} channelId - ID of the channel.
* @param {Array.<number>} messageIds - IDs of the messages to delete.
* @returns {Promise}
*/
trashMessages(channelId, messageIds) {
return this.#deleteRequest(`/channels/${channelId}/messages`, {
message_ids: messageIds,
});
}
/**
* Creates a channel archive.
* @param {number} channelId - The ID of the channel.
* @param {object} data - Params of the archive.
* @param {string} data.selection - "new_topic" or "existing_topic".
* @param {string} [data.title] - Title of the topic when creating a new topic.
* @param {string} [data.category_id] - ID of the category used when creating a new topic.
* @param {Array.<string>} [data.tags] - tags used when creating a new topic.
* @param {string} [data.topic_id] - ID of the topic when using an existing topic.
* @returns {Promise}
*/
createChannelArchive(channelId, data = {}) {
return this.#postRequest(`/channels/${channelId}/archives`, {
archive: data,
});
}
/**
* Updates a channel.
* @param {number} channelId - The ID of the channel.
* @param {object} data - Params of the archive.
* @param {string} [data.description] - Description of the channel.
* @param {string} [data.name] - Name of the channel.
* @returns {Promise}
*/
updateChannel(channelId, data = {}) {
return this.#putRequest(`/channels/${channelId}`, { channel: data });
}
/**
* Creates a thread.
* @param {number} channelId - The ID of the channel.
* @param {number} originalMessageId - The ID of the original message.
* @param {object} data - Params of the thread.
* @param {string} [data.title] - Title of the thread.
* @returns {Promise}
*/
createThread(channelId, originalMessageId, data = {}) {
return this.#postRequest(`/channels/${channelId}/threads`, {
title: data.title,
original_message_id: originalMessageId,
});
}
/**
* Creates a message interaction.
* @param {number} channelId - The ID of the channel.
* @param {number} messageId - The ID of the message.
* @param {object} data - Params of the intereaction.
* @param {string} data.action_id - The ID of the action.
* @returns {Promise}
*/
createInteraction(channelId, messageId, data = {}) {
return this.#postRequest(
`/channels/${channelId}/messages/${messageId}/interactions`,
data
);
}
/**
* Updates the status of a channel.
* @param {number} channelId - The ID of the channel.
* @param {string} status - The new status, can be "open" or "closed".
* @returns {Promise}
*/
updateChannelStatus(channelId, status) {
return this.#putRequest(`/channels/${channelId}/status`, { status });
}
/**
* Lists members of a channel.
* @param {number} channelId - The ID of the channel.
* @returns {Collection}
*/
listChannelMemberships(channelId, params = {}) {
return new Collection(
`${this.#basePath}/channels/${channelId}/memberships`,
(response) => {
return response.memberships.map((membership) =>
UserChatChannelMembership.create(membership)
);
},
params
);
}
/**
* Lists public and direct message channels of the current user.
* @returns {Promise}
*/
listCurrentUserChannels() {
return this.#getRequest("/me/channels");
}
/**
* Makes current user follow a channel.
* @param {number} channelId - The ID of the channel.
* @returns {Promise}
*/
followChannel(channelId) {
return this.#postRequest(`/channels/${channelId}/memberships/me`).then(
(result) => UserChatChannelMembership.create(result.membership)
);
}
/**
* Makes current user unfollow a channel.
* @param {number} channelId - The ID of the channel.
* @returns {Promise}
*/
unfollowChannel(channelId) {
return this.#deleteRequest(
`/channels/${channelId}/memberships/me/follows`
).then((result) => UserChatChannelMembership.create(result.membership));
}
/**
* Destroys the membership of current user on a channel.
*
* @param {number} channelId - The ID of the channel.
* @returns {Promise}
*/
async leaveChannel(channelId) {
await this.#deleteRequest(`/channels/${channelId}/memberships/me`);
const channel = await this.chatChannelsManager.find(channelId, {
fetchIfNotFound: false,
});
if (channel) {
this.chatChannelsManager.remove(channel);
}
}
/**
* Get the list of tracked threads for the current user.
*
* @returns {Promise}
*/
userThreads(handler) {
return new Collection(`${this.#basePath}/me/threads`, handler);
}
/**
* Update notifications settings of current user for a channel.
* @param {number} channelId - The ID of the channel.
* @param {object} data - The settings to modify.
* @param {boolean} [data.muted] - Mutes the channel.
* @param {string} [data.notification_level] - Notifications level: never, mention or always.
* @returns {Promise}
*/
updateCurrentUserChannelNotificationsSettings(channelId, data = {}) {
return this.#putRequest(
`/channels/${channelId}/notifications-settings/me`,
{ notifications_settings: data }
);
}
/**
* Update notifications settings of current user for a thread.
* @param {number} channelId - The ID of the channel.
* @param {number} threadId - The ID of the thread.
* @param {object} data - The settings to modify.
* @param {boolean} [data.notification_level] - The new notification level, c.f. Chat::NotificationLevels. Threads only support
* "regular" and "tracking" for now.
* @returns {Promise}
*/
updateCurrentUserThreadNotificationsSettings(channelId, threadId, data) {
return this.#putRequest(
`/channels/${channelId}/threads/${threadId}/notifications-settings/me`,
{ notification_level: data.notificationLevel }
);
}
/**
* Update thread title prompt of current user for a thread.
* @param {number} channelId - The ID of the channel.
* @param {number} threadId - The ID of the thread.
* @returns {Promise}
*/
updateCurrentUserThreadTitlePrompt(channelId, threadId) {
return this.#postRequest(
`/channels/${channelId}/threads/${threadId}/mark-thread-title-prompt-seen/me`
);
}
/**
* Saves a draft for the channel, which includes message contents and uploads.
* @param {number} channelId - The ID of the channel.
* @param {object} data - The draft data, see ChatMessage.toJSONDraft() for more details.
* @returns {Promise}
*/
saveDraft(channelId, data, options = {}) {
let endpoint = `/chat/api/channels/${channelId}`;
if (options.threadId) {
endpoint += `/threads/${options.threadId}`;
}
endpoint += "/drafts";
return ajax(endpoint, {
type: "POST",
data: {
data,
},
ignoreUnsent: false,
})
.then(() => {
this.chat.markNetworkAsReliable();
})
.catch((error) => {
// we ignore a draft which can't be saved because it's too big
// and only deal with network error for now
if (!error.jqXHR?.responseJSON?.errors?.length) {
this.chat.markNetworkAsUnreliable();
}
});
}
/**
* Adds or removes an emoji reaction for a message inside a channel.
* @param {number} channelId - The ID of the channel.
* @param {number} messageId - The ID of the message to react on.
* @param {string} emoji - The text version of the emoji without colons, e.g. tada
* @param {string} reaction - Either "add" or "remove"
* @returns {Promise}
*/
publishReaction(channelId, messageId, emoji, reactAction) {
return ajax(`/chat/${channelId}/react/${messageId}`, {
type: "PUT",
data: {
react_action: reactAction,
emoji,
},
});
}
/**
* Restores a single deleted chat message in a channel.
*
* @param {number} channelId - The ID of the channel for the message being restored.
* @param {number} messageId - The ID of the message being restored.
*/
restoreMessage(channelId, messageId) {
return this.#putRequest(
`/channels/${channelId}/messages/${messageId}/restore`
);
}
/**
* Rebakes the cooked HTML of a single message in a channel.
*
* @param {number} channelId - The ID of the channel for the message being restored.
* @param {number} messageId - The ID of the message being restored.
*/
rebakeMessage(channelId, messageId) {
return ajax(`/chat/${channelId}/${messageId}/rebake`, {
type: "PUT",
});
}
/**
* Saves an edit to a message's contents in a channel.
*
* @param {number} channelId - The ID of the channel for the message being edited.
* @param {number} messageId - The ID of the message being edited.
* @param {object} data - Params of the edit.
* @param {string} data.new_message - The edited content of the message.
* @param {Array<number>} data.upload_ids - The uploads attached to the message after editing.
*/
editMessage(channelId, messageId, data) {
return this.#putRequest(
`/channels/${channelId}/messages/${messageId}`,
data
);
}
/**
* Marks messages for all of a user's chat channel memberships as read.
*
* @returns {Promise}
*/
markAllChannelsAsRead() {
return this.#putRequest(`/channels/read`);
}
/**
* Lists all possible chatables.
*
* @param {term} string - The term to search for. # prefix will scope to channels, @ to users.
*
* @returns {Promise}
*/
chatables(args = {}) {
return this.#getRequest("/chatables", args);
}
/**
* Marks messages for a single user chat channel membership as read. If no
* message ID is provided, then the latest message for the channel is fetched
* on the server and used for the last read message.
*
* @param {number} channelId - The ID of the channel for the message being marked as read.
* @param {number} [messageId] - The ID of the message being marked as read.
* @returns {Promise}
*/
markChannelAsRead(channelId, messageId = null) {
return this.#putRequest(
`/channels/${channelId}/read?message_id=${messageId}`
);
}
/**
* Marks messages for a single user chat thread membership as read. If no
* message ID is provided, then the latest message for the channel is fetched
* on the server and used for the last read message.
*
* @param {number} channelId - The ID of the channel for the thread being marked as read.
* @param {number} threadId - The ID of the thread being marked as read.
* @param {number} messageId - The ID of the message being marked as read.
* @returns {Promise}
*/
markThreadAsRead(channelId, threadId, messageId) {
return this.#putRequest(
`/channels/${channelId}/threads/${threadId}/read?message_id=${messageId}`
);
}
/**
* Updates settings of a thread.
*
* @param {number} channelId - The ID of the channel for the thread being edited.
* @param {number} threadId - The ID of the thread being edited.
* @param {object} data - Params of the edit.
* @param {string} data.title - The new title for the thread.
*/
editThread(channelId, threadId, data) {
return this.#putRequest(`/channels/${channelId}/threads/${threadId}`, data);
}
/**
* Generate a quote for a list of messages.
*
* @param {number} channelId - The ID of the channel containing the messages.
* @param {Array<number>} messageIds - The IDs of the messages to quote.
*/
generateQuote(channelId, messageIds) {
return ajax(`/chat/${channelId}/quote`, {
type: "POST",
data: { message_ids: messageIds },
});
}
/**
* Invite users to a channel.
*
* @param {number} channelId - The ID of the channel.
* @param {Array<number>} userIds - The IDs of the users to invite.
* @param {object} options
* @param {number} options.chat_message_id - A message ID to display in the invite.
*/
invite(channelId, userIds, options = {}) {
return this.#postRequest(`/channels/${channelId}/invites`, {
user_ids: userIds,
message_id: options.messageId,
});
}
/**
* Add members to a channel.
*
* @param {number} channelId - The ID of the channel.
* @param {object} targets
* @param {Array<string>} targets.usernames - The usernames of the users to add.
* @param {Array<string>} targets.groups - The groups names of the groups to add.
*/
addMembersToChannel(channelId, targets) {
return this.#postRequest(`/channels/${channelId}/memberships`, {
usernames: targets.usernames,
groups: targets.groups,
});
}
get #basePath() {
return "/chat/api";
}
#getRequest(endpoint, data = {}) {
return ajax(`${this.#basePath}${endpoint}`, {
type: "GET",
data,
});
}
#putRequest(endpoint, data = {}) {
return ajax(`${this.#basePath}${endpoint}`, {
type: "PUT",
data,
});
}
#postRequest(endpoint, data = {}) {
return ajax(`${this.#basePath}${endpoint}`, {
type: "POST",
data,
});
}
#deleteRequest(endpoint, data = {}) {
return ajax(`${this.#basePath}${endpoint}`, {
type: "DELETE",
data,
});
}
}