From 345be518f9430e0a1c8465b460a3868d14c9d711 Mon Sep 17 00:00:00 2001 From: Julian Jelfs Date: Sun, 22 Dec 2024 20:35:47 +0000 Subject: [PATCH 1/5] bot message stuff --- .../components/bots/BotMessageContext.svelte | 25 ++++++++ .../app/src/components/home/ChatEvent.svelte | 1 + .../src/components/home/ChatMessage.svelte | 57 +++++++++++-------- .../src/services/common/chatMappersV2.ts | 11 ++++ .../src/services/openchatAgent.ts | 9 +++ frontend/openchat-client/src/openchat.ts | 4 +- .../openchat-shared/src/domain/chat/chat.ts | 7 +++ 7 files changed, 88 insertions(+), 26 deletions(-) create mode 100644 frontend/app/src/components/bots/BotMessageContext.svelte diff --git a/frontend/app/src/components/bots/BotMessageContext.svelte b/frontend/app/src/components/bots/BotMessageContext.svelte new file mode 100644 index 0000000000..32426f30aa --- /dev/null +++ b/frontend/app/src/components/bots/BotMessageContext.svelte @@ -0,0 +1,25 @@ + + +
+ +
+ + diff --git a/frontend/app/src/components/home/ChatEvent.svelte b/frontend/app/src/components/home/ChatEvent.svelte index 94d69519bb..528ae86e10 100644 --- a/frontend/app/src/components/home/ChatEvent.svelte +++ b/frontend/app/src/components/home/ChatEvent.svelte @@ -160,6 +160,7 @@ {supportsEdit} {supportsReply} {collapsed} + botContext={event.event.botContext} on:chatWith on:goToMessageIndex on:replyPrivatelyTo diff --git a/frontend/app/src/components/home/ChatMessage.svelte b/frontend/app/src/components/home/ChatMessage.svelte index 0603584332..9af2c67959 100644 --- a/frontend/app/src/components/home/ChatMessage.svelte +++ b/frontend/app/src/components/home/ChatMessage.svelte @@ -22,6 +22,7 @@ currentCommunityMembers as communityMembers, currentChatMembersMap as chatMembersMap, currentChatBlockedUsers, + type BotMessageContext as BotMessageContextType, } from "openchat-client"; import EmojiPicker from "./EmojiPicker.svelte"; import Avatar from "../Avatar.svelte"; @@ -63,6 +64,7 @@ import WithRole from "./profile/WithRole.svelte"; import RoleIcon from "./profile/RoleIcon.svelte"; import Badges from "./profile/Badges.svelte"; + import BotMessageContext from "../bots/BotMessageContext.svelte"; const client = getContext("client"); const dispatch = createEventDispatcher(); @@ -99,6 +101,7 @@ export let dateFormatter: (date: Date) => string = (date) => client.toShortTimeString(date); export let collapsed: boolean = false; export let threadRootMessage: Message | undefined; + export let botContext: BotMessageContextType | undefined; // this is not to do with permission - some messages (namely thread root messages) will simply not support replying or editing inside a thread export let supportsEdit: boolean; @@ -478,29 +481,36 @@ class:rtl={$rtlStore}> {#if first && !isProposal && !isPrize}
- -

- {senderDisplayName} -

- - {#if sender !== undefined && multiUserChat} - - - - - {/if} - + {#if botContext !== undefined} + + {:else} + +

+ {senderDisplayName} +

+ + {#if sender !== undefined && multiUserChat} + + + + + {/if} + + {/if} {#if senderTyping} @@ -591,6 +601,7 @@
timestamp: {timestamp}
expiresAt: {expiresAt}
thread: {JSON.stringify(msg.thread, null, 4)}
+
botContext: {JSON.stringify(botContext, null, 4)}
{/if} {#if showChatMenu && intersecting} diff --git a/frontend/openchat-agent/src/services/common/chatMappersV2.ts b/frontend/openchat-agent/src/services/common/chatMappersV2.ts index 7ca9cae0be..6adce511fb 100644 --- a/frontend/openchat-agent/src/services/common/chatMappersV2.ts +++ b/frontend/openchat-agent/src/services/common/chatMappersV2.ts @@ -125,6 +125,7 @@ import type { SlashCommandSchema, SlashCommandParamType, SlashCommandParam, + BotMessageContext, } from "openchat-shared"; import { ProposalDecisionStatus, @@ -237,6 +238,7 @@ import type { ImageContent as TImageContent, LocalUserIndexJoinGroupResponse, Message as TMessage, + BotMessageContext as TBotMessageContext, MessageContent as TMessageContent, MessageContentInitial as TMessageContentInitial, MessageMatch as TMessageMatch, @@ -556,6 +558,15 @@ export function message(value: TMessage): Message { deleted: content.kind === "deleted_content", thread: mapOptional(value.thread_summary, threadSummary), blockLevelMarkdown: value.block_level_markdown, + botContext: mapOptional(value.bot_context, botMessageContext), + }; +} + +export function botMessageContext(value: TBotMessageContext): BotMessageContext { + return { + initiator: principalBytesToString(value.initiator), + commandText: value.command_text, + finalised: value.finalised, }; } diff --git a/frontend/openchat-agent/src/services/openchatAgent.ts b/frontend/openchat-agent/src/services/openchatAgent.ts index 02f9bc8481..3542dcfa6e 100644 --- a/frontend/openchat-agent/src/services/openchatAgent.ts +++ b/frontend/openchat-agent/src/services/openchatAgent.ts @@ -1423,6 +1423,15 @@ export class OpenChatAgent extends EventTarget { rehydrateUserSummary(userSummary: T): T { const ref = userSummary.blobReference; + if (userSummary.kind === "bot") { + return { + ...userSummary, + blobData: undefined, + blobUrl: `${this.config.blobUrlPattern + .replace("{canisterId}", this.config.userIndexCanister) + .replace("{blobType}", "avatar")}/${userSummary.userId}/${ref?.blobId}`, + }; + } return { ...userSummary, blobData: undefined, diff --git a/frontend/openchat-client/src/openchat.ts b/frontend/openchat-client/src/openchat.ts index 86cbad2ab0..cef1705308 100644 --- a/frontend/openchat-client/src/openchat.ts +++ b/frontend/openchat-client/src/openchat.ts @@ -7852,9 +7852,7 @@ export class OpenChat extends EventTarget { messageId: random64(), commandName: bot.command.name, parameters: JSON.stringify(bot.command.params), - commandText: `@${this.getDisplayName( - this.#liveState.user, - )} executed the command /${bot.command.name}`, + commandText: `/${bot.command.name}`, botId: bot.id, userId: this.#liveState.user.userId, }, diff --git a/frontend/openchat-shared/src/domain/chat/chat.ts b/frontend/openchat-shared/src/domain/chat/chat.ts index 38f5259784..f896c476b3 100644 --- a/frontend/openchat-shared/src/domain/chat/chat.ts +++ b/frontend/openchat-shared/src/domain/chat/chat.ts @@ -653,6 +653,13 @@ export type Message = { deleted: boolean; thread?: ThreadSummary; blockLevelMarkdown: boolean; + botContext?: BotMessageContext; +}; + +export type BotMessageContext = { + initiator: string; + commandText: string; + finalised: boolean; }; export type ThreadSummary = { From 5d0bdd82c48547edef34521428adfecd89a36bd6 Mon Sep 17 00:00:00 2001 From: Julian Jelfs Date: Mon, 23 Dec 2024 12:09:14 +0000 Subject: [PATCH 2/5] get transient message from bot --- .../src/components/home/ChatMessage.svelte | 3 +- .../src/services/externalBot/externalBot.ts | 77 ++++++++++++- frontend/openchat-client/src/openchat.ts | 101 +++++++++++------- frontend/openchat-shared/src/domain/bots.ts | 19 +++- frontend/openchat-shared/src/domain/worker.ts | 15 ++- frontend/openchat-worker/src/worker.ts | 9 ++ 6 files changed, 178 insertions(+), 46 deletions(-) diff --git a/frontend/app/src/components/home/ChatMessage.svelte b/frontend/app/src/components/home/ChatMessage.svelte index 9af2c67959..2c671e8734 100644 --- a/frontend/app/src/components/home/ChatMessage.svelte +++ b/frontend/app/src/components/home/ChatMessage.svelte @@ -161,6 +161,7 @@ msg.content.kind === "deleted_content" && Number(msg.content.timestamp) < $now - 5 * 60 * 1000; $: canRevealDeleted = deletedByMe && !undeleting && !permanentlyDeleted; + $: edited = msg.edited && !botContext?.finalised; onMount(() => { if (!readByMe) { @@ -558,7 +559,7 @@ messageId={msg.messageId} myUserId={user.userId} content={msg.content} - edited={msg.edited} + {edited} height={mediaCalculatedHeight} blockLevelMarkdown={msg.blockLevelMarkdown} on:removePreview diff --git a/frontend/openchat-agent/src/services/externalBot/externalBot.ts b/frontend/openchat-agent/src/services/externalBot/externalBot.ts index 9e38a7accd..1e64bafee8 100644 --- a/frontend/openchat-agent/src/services/externalBot/externalBot.ts +++ b/frontend/openchat-agent/src/services/externalBot/externalBot.ts @@ -1,8 +1,9 @@ -import { type BotDefinitionResponse } from "openchat-shared"; +import { type BotCommandResponse, type BotDefinitionResponse } from "openchat-shared"; import { Value, AssertError } from "@sinclair/typebox/value"; import { Type, type Static } from "@sinclair/typebox"; -import { SlashCommandSchema } from "../../typebox"; -import { externalBotDefinition } from "../common/chatMappersV2"; +import { MessageContent, SlashCommandSchema } from "../../typebox"; +import { externalBotDefinition, messageContent } from "../common/chatMappersV2"; +import { mapOptional } from "../../utils/mapping"; type ApiBotDefinition = Static; const ApiBotDefinition = Type.Object({ @@ -10,6 +11,23 @@ const ApiBotDefinition = Type.Object({ commands: Type.Array(SlashCommandSchema), }); +type ApiBotResponse = Static; +const ApiBotResponse = Type.Union([ + Type.Object({ + Success: Type.Object({ + message: Type.Optional( + Type.Object({ + message_id: Type.String(), + message_content: MessageContent, + }), + ), + }), + }), + Type.Object({ + BadRequest: Type.Any(), + }), +]); + export function getBotDefinition(endpoint: string): Promise { return fetch(`${endpoint}`) .then((res) => { @@ -47,3 +65,56 @@ function formatError(err: unknown) { } return err; } + +function validateBotResponse(json: unknown): BotCommandResponse { + try { + console.log("Bot command response json", json); + const value = Value.Parse(ApiBotResponse, json); + return externalBotResponse(value); + } catch (err) { + return { + kind: "failure", + error: formatError(err), + }; + } +} + +function externalBotResponse(value: ApiBotResponse): BotCommandResponse { + if ("Success" in value) { + return { + kind: "success", + placeholder: mapOptional(value.Success.message, ({ message_id, message_content }) => { + return { + messageId: BigInt(message_id), + messageContent: messageContent(message_content, ""), + }; + }), + }; + } + return { kind: "failure", error: value }; +} + +export function callBotCommandEndpoint( + endpoint: string, + token: string, +): Promise { + const headers = new Headers(); + headers.append("Content-type", "text/plain"); + return fetch(`${endpoint}/execute_command`, { + method: "POST", + headers: headers, + body: token, + }) + .then((res) => { + if (res.ok) { + return res.json(); + } else { + return "InternalError"; + } + }) + .then(validateBotResponse) + .catch((err) => { + console.log("Bot command failed: ", err); + return { kind: "failure", error: err }; + }); +} diff --git a/frontend/openchat-client/src/openchat.ts b/frontend/openchat-client/src/openchat.ts index cef1705308..dffa2ada3a 100644 --- a/frontend/openchat-client/src/openchat.ts +++ b/frontend/openchat-client/src/openchat.ts @@ -409,6 +409,7 @@ import type { ChatEventsArgs, ChatEventsResponse, BotDefinitionResponse, + BotCommandResponse, } from "openchat-shared"; import { Stream, @@ -3896,6 +3897,7 @@ export class OpenChat extends EventTarget { blockLevelMarkdown: boolean, mentioned: User[] = [], forwarded: boolean = false, + msgFn?: (idx: number) => Message, ): Promise { const { chatId, threadRootMessageIndex } = messageContext; const chat = this.#liveState.chatSummaries.get(chatId); @@ -3910,14 +3912,17 @@ export class OpenChat extends EventTarget { ? nextEventAndMessageIndexesForThread(currentEvents) : nextEventAndMessageIndexes(); - const msg = this.#createMessage( - this.#liveState.user.userId, - nextMessageIndex, - content, - blockLevelMarkdown, - draftMessage?.replyingTo, - forwarded, - ); + const msg = msgFn + ? msgFn(nextMessageIndex) + : this.#createMessage( + this.#liveState.user.userId, + nextMessageIndex, + content, + blockLevelMarkdown, + draftMessage?.replyingTo, + forwarded, + ); + const timestamp = Date.now(); const event = { event: msg, @@ -7755,23 +7760,11 @@ export class OpenChat extends EventTarget { }); } - #callBotCommandEndpoint(bot: ExternalBotCommandInstance, token: string): Promise { - const headers = new Headers(); - headers.append("Content-type", "text/plain"); - return fetch(`${bot.endpoint}/execute_command`, { - method: "POST", - headers: headers, - body: token, - }).then((res) => { - if (res.ok) { - return res.json(); - } else { - const msg = `Failed to execute external bot command: ${res.status}, ${ - res.statusText - }, ${JSON.stringify(bot)}`; - console.error(msg); - throw new Error(msg); - } + #callBotCommandEndpoint(endpoint: string, token: string): Promise { + return this.#sendRequest({ + kind: "callBotCommandEndpoint", + endpoint, + token, }); } @@ -7922,24 +7915,54 @@ export class OpenChat extends EventTarget { switch (bot.kind) { case "external_bot": return this.#getAuthTokenForBotCommand(chat, threadRootMessageIndex, bot) - .then((token) => this.#callBotCommandEndpoint(bot, token)) + .then((token) => this.#callBotCommandEndpoint(bot.endpoint, token)) .then((resp) => { - if (bot.command.name === "chat") { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - //@ts-ignore - const r: { response: string; success: boolean } = resp; - this.sendMessageWithAttachment( - bot.command.messageContext, - r.response, - false, - undefined, - [], - ); + if (resp.kind === "failure") { + console.error("Bot command failed with: ", resp.error); + } else { + if (resp.placeholder !== undefined) { + // we need to somehow act like this message has been sent in the front end + const currentEvents = this.#eventsForMessageContext( + bot.command.messageContext, + ); + const [eventIndex, messageIndex] = + threadRootMessageIndex !== undefined + ? nextEventAndMessageIndexesForThread(currentEvents) + : nextEventAndMessageIndexes(); + + this.dispatchEvent(new SendingMessage(bot.command.messageContext)); + + const msg: Message = { + content: resp.placeholder?.messageContent, + messageIndex, + kind: "message", + sender: bot.id, + messageId: resp.placeholder.messageId, + reactions: [], + tips: {}, + edited: false, + forwarded: false, + deleted: false, + blockLevelMarkdown: false, + }; + const event = { + index: eventIndex, + timestamp: BigInt(Date.now()), + event: msg, + }; + + window.setTimeout(() => { + unconfirmed.add(bot.command.messageContext, event); + this.dispatchEvent( + new SentMessage(bot.command.messageContext, event), + ); + }, 0); + } } - return true; + return resp.kind === "success"; }) .catch((err) => { - console.log("Failed to execute bot command: ", err); + console.log("Bot command failed with", err); return false; }); case "internal_bot": diff --git a/frontend/openchat-shared/src/domain/bots.ts b/frontend/openchat-shared/src/domain/bots.ts index 69532babab..b0abd4521d 100644 --- a/frontend/openchat-shared/src/domain/bots.ts +++ b/frontend/openchat-shared/src/domain/bots.ts @@ -1,5 +1,5 @@ import { Principal } from "@dfinity/principal"; -import type { MessageContext } from "./chat"; +import type { MessageContent, MessageContext } from "./chat"; import type { ChatPermissions, CommunityPermissions, MessagePermission } from "./permission"; import type { InterpolationValues, ResourceKey } from "../utils"; import { ValidationErrors } from "../utils/validation"; @@ -512,3 +512,20 @@ function validCanister(canister: string | undefined): boolean { return false; } } + +export type BotCommandResponse = BotCommandSuccess | BotCommandFailure; + +export type BotCommandFailure = { + kind: "failure"; + error: unknown; +}; + +export type BotCommandSuccess = { + kind: "success"; + placeholder?: BotResponseMessage; +}; + +export type BotResponseMessage = { + messageId: bigint; + messageContent: MessageContent; +}; diff --git a/frontend/openchat-shared/src/domain/worker.ts b/frontend/openchat-shared/src/domain/worker.ts index 896dec3510..8a5c261582 100644 --- a/frontend/openchat-shared/src/domain/worker.ts +++ b/frontend/openchat-shared/src/domain/worker.ts @@ -202,6 +202,7 @@ import type { import type { JsonnableDelegationChain } from "@dfinity/identity"; import type { Verification } from "./wallet"; import type { + BotCommandResponse, BotDefinitionResponse, BotsResponse, ExternalBot, @@ -421,7 +422,14 @@ export type WorkerRequest = | RemoveInstalledBot | UpdateInstalledBot | UpdateRegisteredBot - | GetBotDefinition; + | GetBotDefinition + | CallBotCommandEndpoint; + +type CallBotCommandEndpoint = { + kind: "callBotCommandEndpoint"; + endpoint: string; + token: string; +}; type GetBotDefinition = { kind: "getBotDefinition"; @@ -1572,7 +1580,8 @@ export type WorkerResponseInner = | ExternalAchievement[] | MessageActivityFeedResponse | ExploreBotsResponse - | BotDefinitionResponse; + | BotDefinitionResponse + | BotCommandResponse; export type WorkerResponse = Response; @@ -2284,6 +2293,8 @@ export type WorkerResult = T extends Init ? boolean : T extends GetBotDefinition ? BotDefinitionResponse + : T extends CallBotCommandEndpoint + ? BotCommandResponse : T extends RemoveInstalledBot ? boolean : T extends UpdateInstalledBot diff --git a/frontend/openchat-worker/src/worker.ts b/frontend/openchat-worker/src/worker.ts index c1bf0450aa..ac933d4363 100644 --- a/frontend/openchat-worker/src/worker.ts +++ b/frontend/openchat-worker/src/worker.ts @@ -12,6 +12,7 @@ import { setCommunityReferral, getBotDefinition, } from "openchat-agent"; +import { callBotCommandEndpoint } from "openchat-agent/lib/services/externalBot/externalBot"; import { type CorrelatedWorkerRequest, type Init, @@ -1914,6 +1915,14 @@ self.addEventListener("message", (msg: MessageEvent) => executeThenReply(payload, correlationId, getBotDefinition(payload.endpoint)); break; + case "callBotCommandEndpoint": + executeThenReply( + payload, + correlationId, + callBotCommandEndpoint(payload.endpoint, payload.token), + ); + break; + default: logger?.debug("WORKER: unknown message kind received: ", kind); } From 3bd47107f89456a661aca56f02a898d067cacfca Mon Sep 17 00:00:00 2001 From: Julian Jelfs Date: Mon, 23 Dec 2024 12:19:38 +0000 Subject: [PATCH 3/5] fix thread preview --- .../app/src/components/home/thread/ThreadPreview.svelte | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/frontend/app/src/components/home/thread/ThreadPreview.svelte b/frontend/app/src/components/home/thread/ThreadPreview.svelte index cc00fde4e7..af71166225 100644 --- a/frontend/app/src/components/home/thread/ThreadPreview.svelte +++ b/frontend/app/src/components/home/thread/ThreadPreview.svelte @@ -177,7 +177,8 @@ timestamp={thread.rootMessage.timestamp} expiresAt={thread.rootMessage.expiresAt} dateFormatter={(date) => client.toDatetimeString(date)} - msg={thread.rootMessage.event} /> + msg={thread.rootMessage.event} + botContext={thread.rootMessage.event.botContext} />
{#if missingMessages > 0}
@@ -222,7 +223,8 @@ timestamp={evt.timestamp} expiresAt={evt.expiresAt} dateFormatter={(date) => client.toDatetimeString(date)} - msg={evt.event} /> + msg={evt.event} + botContext={evt.event.botContext} /> {/each} {/each} Date: Mon, 23 Dec 2024 14:15:56 +0000 Subject: [PATCH 4/5] fill in a bit more error handling --- .../src/services/externalBot/externalBot.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/frontend/openchat-agent/src/services/externalBot/externalBot.ts b/frontend/openchat-agent/src/services/externalBot/externalBot.ts index 1e64bafee8..ba83c5fec7 100644 --- a/frontend/openchat-agent/src/services/externalBot/externalBot.ts +++ b/frontend/openchat-agent/src/services/externalBot/externalBot.ts @@ -24,7 +24,16 @@ const ApiBotResponse = Type.Union([ }), }), Type.Object({ - BadRequest: Type.Any(), + BadRequest: Type.Union([ + Type.Literal("AccessTokenNotFound"), + Type.Literal("AccessTokenInvalid"), + Type.Literal("AccessTokenExpired"), + Type.Literal("CommandNotFound"), + Type.Literal("ArgsInvalid"), + ]), + }), + Type.Object({ + InternalError: Type.Any(), }), ]); From fbe05bb2d313761e2fa572929407ee39de08d3b9 Mon Sep 17 00:00:00 2001 From: Julian Jelfs Date: Mon, 23 Dec 2024 14:31:56 +0000 Subject: [PATCH 5/5] fill in bot context in unconfirmed message --- frontend/openchat-client/src/openchat.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/frontend/openchat-client/src/openchat.ts b/frontend/openchat-client/src/openchat.ts index dffa2ada3a..88ee87a27f 100644 --- a/frontend/openchat-client/src/openchat.ts +++ b/frontend/openchat-client/src/openchat.ts @@ -7944,6 +7944,11 @@ export class OpenChat extends EventTarget { forwarded: false, deleted: false, blockLevelMarkdown: false, + botContext: { + initiator: this.#liveState.user.userId, + finalised: false, + commandText: `/${bot.command.name}`, + }, }; const event = { index: eventIndex,