fix(stage-ui): preserve and propagate discord context in bridge (#934)

---------

Co-authored-by: Neko Ayaka <neko@ayaka.moe>
This commit is contained in:
skyline624
2026-01-11 23:42:45 +08:00
committed by GitHub
co-authored by Neko Ayaka
parent 00666852fc
commit b500eb8f5b
17 changed files with 149 additions and 110 deletions
@@ -74,11 +74,7 @@ function handleActivate(char: Character) {
v-model="searchQuery"
placeholder="Search..."
class="w-64"
>
<template #prefix>
<div class="i-solar:magnifer-linear text-neutral-400" />
</template>
</FieldInput>
/>
<Button @click="handleCreate">
<div class="i-solar:add-circle-bold mr-2" />
Create New
+2 -1
View File
@@ -39,6 +39,7 @@
"@proj-airi/server-shared": "workspace:^",
"crossws": "^0.4.1",
"h3": "^2.0.1-rc.6",
"listhen": "^1.9.0"
"listhen": "^1.9.0",
"nanoid": "catalog:"
}
}
+50 -49
View File
@@ -11,6 +11,9 @@ import type { AuthenticatedPeer, Peer } from './types'
import { availableLogLevelStrings, Format, LogLevelString, logLevelStringToLogLevelMap, useLogg } from '@guiiai/logg'
import { MessageHeartbeat, MessageHeartbeatKind, WebSocketEventSource } from '@proj-airi/server-shared/types'
import { defineWebSocketHandler, H3 } from 'h3'
import { nanoid } from 'nanoid'
import packageJSON from '../package.json'
import { optionOrEnv } from './config'
import {
@@ -20,10 +23,42 @@ import {
matchesDestinations,
} from './middlewares'
function createServerEventMetadata(serverInstanceId: string, parentId?: string) {
return {
event: {
id: nanoid(),
parentId,
},
source: {
plugin: WebSocketEventSource.Server,
instanceId: serverInstanceId,
version: packageJSON.version,
},
}
}
// pre-stringified responses
const RESPONSES = {
authenticated: JSON.stringify({ type: 'module:authenticated', data: { authenticated: true }, source: WebSocketEventSource.Server } satisfies WebSocketEvent),
notAuthenticated: JSON.stringify({ type: 'error', data: { message: 'not authenticated' }, source: WebSocketEventSource.Server } satisfies WebSocketEvent),
authenticated: (serverInstanceId: string, parentId?: string) => JSON.stringify({
type: 'module:authenticated',
data: { authenticated: true },
metadata: createServerEventMetadata(serverInstanceId, parentId),
} satisfies WebSocketEvent),
notAuthenticated: (serverInstanceId: string, parentId?: string) => JSON.stringify({
type: 'error',
data: { message: 'not authenticated' },
metadata: createServerEventMetadata(serverInstanceId, parentId),
} satisfies WebSocketEvent),
error: (message: string, serverInstanceId: string, parentId?: string) => JSON.stringify({
type: 'error',
data: { message },
metadata: createServerEventMetadata(serverInstanceId, parentId),
}),
heartbeat: (kind: MessageHeartbeatKind, message: MessageHeartbeat | string, serverInstanceId: string, parentId?: string) => JSON.stringify({
type: 'transport:connection:heartbeat',
data: { kind, message, at: Date.now() },
metadata: createServerEventMetadata(serverInstanceId, parentId),
} satisfies WebSocketEvent),
}
const DEFAULT_HEARTBEAT_TTL_MS = 60_000
@@ -34,6 +69,7 @@ function send(peer: Peer, event: WebSocketEvent<Record<string, unknown>> | strin
}
export function setupApp(options?: {
instanceId?: string
auth?: {
token: string
}
@@ -51,6 +87,7 @@ export function setupApp(options?: {
message?: MessageHeartbeat | string
}
}): H3 {
const instanceId = options?.instanceId || optionOrEnv(undefined, 'SERVER_INSTANCE_ID', nanoid())
const authToken = optionOrEnv(options?.auth?.token, 'AUTHENTICATION_TOKEN', '')
const appLogLevel = optionOrEnv(options?.logger?.app?.level, 'LOG_LEVEL', LogLevelString.Log, { validator: (value): value is LogLevelString => availableLogLevelStrings.includes(value as LogLevelString) })
@@ -144,7 +181,7 @@ export function setupApp(options?: {
}
catch (err) {
const errorMessage = err instanceof Error ? err.message : String(err)
send(peer, { type: 'error', data: { message: `invalid JSON, error: ${errorMessage}` }, source: WebSocketEventSource.Server })
send(peer, RESPONSES.error(`invalid JSON, error: ${errorMessage}`, instanceId))
return
}
@@ -171,15 +208,7 @@ export function setupApp(options?: {
}
if (event.data.kind === MessageHeartbeatKind.Ping) {
send(peer, {
type: 'transport:connection:heartbeat',
data: {
kind: MessageHeartbeatKind.Pong,
message: heartbeatMessage,
at: Date.now(),
},
source: WebSocketEventSource.Server,
})
send(peer, RESPONSES.heartbeat(MessageHeartbeatKind.Pong, heartbeatMessage, instanceId, event.metadata?.event.id))
}
return
@@ -188,11 +217,7 @@ export function setupApp(options?: {
case 'module:authenticate': {
if (authToken && event.data.token !== authToken) {
logger.withFields({ peer: peer.id, peerRemote: peer.remoteAddress, peerRequest: peer.request.url }).log('authentication failed')
send(peer, {
type: 'error',
data: { message: 'invalid token' },
source: WebSocketEventSource.Server,
})
send(peer, RESPONSES.error('invalid token', instanceId, event.metadata?.event.id))
return
}
@@ -217,31 +242,19 @@ export function setupApp(options?: {
// verify
const { name, index, identity } = event.data as { name: string, index?: number, identity?: MetadataEventSource }
if (!name || typeof name !== 'string') {
send(peer, {
type: 'error',
data: { message: 'the field \'name\' must be a non-empty string for event \'module:announce\'' },
source: WebSocketEventSource.Server,
})
send(peer, RESPONSES.error('the field \'name\' must be a non-empty string for event \'module:announce\'', instanceId))
return
}
if (typeof index !== 'undefined') {
if (!Number.isInteger(index) || index < 0) {
send(peer, {
type: 'error',
data: { message: 'the field \'index\' must be a non-negative integer for event \'module:announce\'' },
source: WebSocketEventSource.Server,
})
send(peer, RESPONSES.error('the field \'index\' must be a non-negative integer for event \'module:announce\'', instanceId))
return
}
}
if (authToken && !p.authenticated) {
send(peer, {
type: 'error',
data: { message: 'must authenticate before announcing' },
source: WebSocketEventSource.Server,
})
send(peer, RESPONSES.error('must authenticate before announcing', instanceId))
return
}
@@ -261,21 +274,13 @@ export function setupApp(options?: {
const { moduleName, moduleIndex, config } = event.data
if (moduleName === '') {
send(peer, {
type: 'error',
data: { message: 'the field \'moduleName\' can\'t be empty for event \'ui:configure\'' },
source: WebSocketEventSource.Server,
})
send(peer, RESPONSES.error('the field \'moduleName\' can\'t be empty for event \'ui:configure\'', instanceId))
return
}
if (typeof moduleIndex !== 'undefined') {
if (!Number.isInteger(moduleIndex) || moduleIndex < 0) {
send(peer, {
type: 'error',
data: { message: 'the field \'moduleIndex\' must be a non-negative integer for event \'ui:configure\'' },
source: WebSocketEventSource.Server,
})
send(peer, RESPONSES.error('the field \'moduleIndex\' must be a non-negative integer for event \'ui:configure\'', instanceId))
return
}
@@ -286,16 +291,12 @@ export function setupApp(options?: {
send(target.peer, {
type: 'module:configure',
data: { config },
// NOTICE: here we will forward the source as-is
source: event.source,
// NOTICE: this will forward the original event metadata as-is
metadata: event.metadata,
})
}
else {
send(peer, {
type: 'error',
data: { message: 'module not found, it hasn\'t announced itself or the name is incorrect' },
source: WebSocketEventSource.Server,
})
send(peer, RESPONSES.error('module not found, it hasn\'t announced itself or the name is incorrect', instanceId))
}
return
@@ -1,4 +1,4 @@
import type { RouteTargetExpression, WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-shared/types'
import type { RouteTargetExpression, WebSocketBaseEvent, WebSocketEventOf, WebSocketEvents } from '@proj-airi/server-shared/types'
import type { AuthenticatedPeer } from '../types'
@@ -24,20 +24,26 @@ function createPeer(options: {
}
}
function createSparkNotifyEvent(overrides?: Partial<WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify'], any>>): WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify'], any> {
return {
type: 'spark:notify',
data: {
function createSparkNotifyEvent(overrides: Partial<WebSocketEventOf<'spark:notify'>> = {}): WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify'], any> {
const data: WebSocketEvents['spark:notify'] = {
id: 'evt-1',
eventId: 'spark-1',
kind: 'ping',
urgency: 'soon',
headline: 'hello',
destinations: ['module:character'],
},
source: 'proj-airi:server-runtime',
...overrides,
...overrides.data,
}
return {
type: 'spark:notify',
data,
metadata: overrides.metadata ?? {
source: { plugin: 'server-runtime', instanceId: 'test' },
event: { id: data.id },
},
route: overrides.route,
} as WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify'], any>
}
describe('match-expression', () => {
@@ -69,7 +69,7 @@ export function createPolicyMiddleware(policy: RoutingPolicy): RouteMiddleware {
}
}
export function collectDestinations(event: WebSocketEvent) {
export function collectDestinations(event: WebSocketEvent | (Omit<WebSocketEvent, 'metadata'> & Partial<Pick<WebSocketEvent, 'metadata'>>)) {
if (event.route?.destinations?.length) {
return event.route.destinations
}
+12 -3
View File
@@ -4,7 +4,6 @@ import type {
WebSocketEvent,
WebSocketEventOptionalSource,
WebSocketEvents,
WebSocketEventSource,
} from '@proj-airi/server-shared/types'
import WebSocket from 'crossws/websocket'
@@ -38,6 +37,10 @@ function createInstanceId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
}
function createEventId() {
return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`
}
export class Client<C = undefined> {
private connected = false
private connecting = false
@@ -305,9 +308,15 @@ export class Client<C = undefined> {
send(data: WebSocketEventOptionalSource<C>): void {
if (this.websocket && this.connected) {
const payload = {
source: this.opts.name as WebSocketEventSource | string,
metadata: { source: this.identity },
...data,
metadata: {
...data?.metadata,
source: data?.metadata?.source ?? this.identity,
event: {
id: data?.metadata?.event?.id ?? createEventId(),
...data?.metadata?.event,
},
},
} as WebSocketEvent<C>
this.opts.onAnySend?.(payload)
@@ -158,7 +158,15 @@ export type WebSocketEventInputVoice = WebSocketEventInputVoiceBase & Partial<Wi
export type WebSocketEventDataInputs = WebSocketEventInputText | WebSocketEventInputTextVoice | WebSocketEventInputVoice
export type WebSocketEventInputs = WebSocketBaseEvent<'input:text' | 'input:text:voice' | 'input:voice', WebSocketEventInputText | WebSocketEventInputTextVoice | WebSocketEventInputVoice>
export type WebSocketEventInputs = WebSocketEventOf<'input:text'> | WebSocketEventOf<'input:text:voice'> | WebSocketEventOf<'input:voice'>
export interface WebSocketEventBaseMetadata {
source?: MetadataEventSource
event?: {
id?: string
parentId?: string
}
}
export interface WebSocketBaseEvent<T, D, S extends string = string> {
type: T
@@ -167,8 +175,12 @@ export interface WebSocketBaseEvent<T, D, S extends string = string> {
* @deprecated Prefer metadata.source.
*/
source?: WebSocketEventSource | S
metadata?: {
metadata: {
source: MetadataEventSource
event: {
id: string
parentId?: string
}
}
route?: RouteConfig
}
@@ -350,5 +362,9 @@ export type WebSocketEvent<C = undefined> = {
}[keyof WebSocketEvents<C>]
export type WebSocketEventOptionalSource<C = undefined> = {
[K in keyof WebSocketEvents<C>]: Omit<WebSocketBaseEvent<K, WebSocketEvents<C>[K]>, 'source'> & Partial<Pick<WebSocketBaseEvent<K, WebSocketEvents<C>[K]>, 'source'>>;
[K in keyof WebSocketEvents<C>]: Omit<WebSocketBaseEvent<K, WebSocketEvents<C>[K]>, 'metadata'> & { metadata?: WebSocketEventBaseMetadata };
}[keyof WebSocketEvents<C>]
export type WebSocketEventOf<E, C = undefined> = E extends keyof WebSocketEvents<C>
? Omit<WebSocketBaseEvent<E, WebSocketEvents<C>[E]>, 'metadata'> & { metadata?: WebSocketEventBaseMetadata }
: never
@@ -1,5 +1,5 @@
<script setup lang="ts">
import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk'
import type { WebSocketBaseEvent, WebSocketEventOf, WebSocketEvents } from '@proj-airi/server-sdk'
import type { ChatStreamEvent, ContextMessage } from '@proj-airi/stage-ui/types/chat'
import type { FlowDirection, FlowEntry, SparkNotifyEntryState } from './context-flow-types'
@@ -262,7 +262,7 @@ async function sendTestSparkNotify() {
metadata: parsed.metadata && typeof parsed.metadata === 'object' ? parsed.metadata : undefined,
}
const simulatedEvent: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']> = {
const simulatedEvent: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: 'devtools',
data: notify,
@@ -1,4 +1,4 @@
import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk'
import type { WebSocketEventOf, WebSocketEvents } from '@proj-airi/server-sdk'
import type { ChatProvider, ChatProviderWithExtraOptions, EmbedProvider, EmbedProviderWithExtraOptions, SpeechProvider, SpeechProviderWithExtraOptions, TranscriptionProvider, TranscriptionProviderWithExtraOptions } from '@xsai-ext/providers/utils'
import type { Message } from '@xsai/shared-chat'
@@ -57,8 +57,8 @@ export interface SparkNotifyAgentDeps {
getSystemPrompt: () => string
getProcessing: () => boolean
setProcessing: (next: boolean) => void
getPending: () => Array<WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>>
setPending: (next: Array<WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>>) => void
getPending: () => Array<WebSocketEventOf<'spark:notify'>>
setPending: (next: Array<WebSocketEventOf<'spark:notify'>>) => void
}
function getSparkNotifyHandlingAgentInstruction(moduleName: string) {
@@ -101,7 +101,7 @@ export const sparkCommandSchema = z.object({
export type SparkCommandSchema = z.infer<typeof sparkCommandSchema>
export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps) {
async function runNotifyAgent(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>) {
async function runNotifyAgent(event: WebSocketEventOf<'spark:notify'>) {
const activeProvider = deps.getActiveProvider()
const activeModel = deps.getActiveModel()
if (!activeProvider || !activeModel) {
@@ -225,7 +225,7 @@ export function setupAgentSparkNotifyHandler(deps: SparkNotifyAgentDeps) {
} satisfies SparkNotifyResponse
}
async function handle(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>) {
async function handle(event: WebSocketEventOf<'spark:notify'>) {
if (event.data.urgency !== 'immediate' && deps.getPending().length > 0) {
deps.setPending([...deps.getPending(), event])
return undefined
@@ -1,7 +1,7 @@
/* eslint-disable style/indent-binary-ops */
/* eslint-disable style/operator-linebreak */
import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk'
import type { WebSocketEventOf } from '@proj-airi/server-sdk'
import type { Store, StoreDefinition } from 'pinia'
import type { Mock } from 'vitest'
import type { UnwrapRef } from 'vue'
@@ -163,7 +163,7 @@ describe('store character-orchestrator', () => {
mockedStore(useCharacterStore).onSparkNotifyReactionStreamEnd = mockOnSparkNotifyReactionStreamEnd
const store = useCharacterOrchestratorStore()
const event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']> = {
const event: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: 'minecraft',
data: {
@@ -1,4 +1,4 @@
import type { WebSocketBaseEvent, WebSocketEvents } from '@proj-airi/server-sdk'
import type { WebSocketBaseEvent, WebSocketEventOf, WebSocketEvents } from '@proj-airi/server-sdk'
import { defineStore, storeToRefs } from 'pinia'
import { ref } from 'vue'
@@ -22,9 +22,9 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
const modsServerChannelStore = useModsServerChannelStore()
const processing = ref(false)
const pendingNotifies = ref<Array<WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>>>([])
const pendingNotifies = ref<Array<WebSocketEventOf<'spark:notify'>>>([])
const scheduledNotifies = ref<Array<{
event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>
event: WebSocketEventOf<'spark:notify'>
enqueuedAt: number
nextRunAt: number
attempts: number
@@ -52,7 +52,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
setPending: next => pendingNotifies.value = next,
})
function computeNextRunAt(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>, attempts: number) {
function computeNextRunAt(event: WebSocketEventOf<'spark:notify'>, attempts: number) {
const now = Date.now()
const baseDelay = (() => {
switch (event.data.urgency) {
@@ -74,7 +74,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
pendingNotifies.value = pendingNotifies.value.filter(item => item.data.id !== eventId)
}
function enqueueSparkNotify(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>, options?: { reason?: string, nextRunAt?: number, maxAttempts?: number }) {
function enqueueSparkNotify(event: WebSocketEventOf<'spark:notify'>, options?: { reason?: string, nextRunAt?: number, maxAttempts?: number }) {
if (!pendingNotifies.value.find(item => item.data.id === event.data.id)) {
pendingNotifies.value = [...pendingNotifies.value, event]
}
@@ -89,7 +89,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
}]
}
async function processSparkNotify(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>) {
async function processSparkNotify(event: WebSocketEventOf<'spark:notify'>) {
const result = await sparkNotifyAgent.handle(event)
if (!result?.commands?.length)
return result
@@ -104,7 +104,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
return result
}
async function handleIncomingSparkNotify(event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']>) {
async function handleIncomingSparkNotify(event: WebSocketEventOf<'spark:notify'>) {
if (event.data.urgency === 'immediate' && !processing.value) {
return await processSparkNotify(event)
}
@@ -119,7 +119,7 @@ export const useCharacterOrchestratorStore = defineStore('character-orchestrator
return
for (const task of dueTasks) {
const event: WebSocketBaseEvent<'spark:notify', WebSocketEvents['spark:notify']> = {
const event: WebSocketEventOf<'spark:notify'> = {
type: 'spark:notify',
source: 'character:task-scheduler',
data: {
@@ -101,6 +101,7 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
input: {
type: 'input:text',
data: {
...event.data,
text,
textRaw,
overrides,
@@ -165,8 +166,8 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
serverChannelStore.send({
type: 'output:gen-ai:chat:message',
data: {
...context.input?.data,
message,
...context.input?.metadata?.source,
'stage-web': isStageWeb(),
'stage-tamagotchi': isStageTamagotchi(),
'gen-ai:chat': {
@@ -183,8 +184,9 @@ export const useContextBridgeStore = defineStore('mods:api:context-bridge', () =
serverChannelStore.send({
type: 'output:gen-ai:chat:complete',
data: {
...context.input?.data,
'message': chat.output,
...context.input?.metadata?.source,
// TODO: tool calls should be captured properly
'toolCalls': [],
'stage-web': isStageWeb(),
'stage-tamagotchi': isStageTamagotchi(),
@@ -1,4 +1,8 @@
<script setup lang="ts">
<script
setup
lang="ts"
generic="InputType extends 'number' | string, T = InputType extends 'number' ? (number | undefined) : ((string | undefined))"
>
import { Input } from '../input'
const props = withDefaults(defineProps<{
@@ -6,14 +10,14 @@ const props = withDefaults(defineProps<{
description?: string
placeholder?: string
required?: boolean
type?: string
type?: InputType
inputClass?: string
singleLine?: boolean
}>(), {
singleLine: true,
})
const modelValue = defineModel<string | number>({ required: false })
const modelValue = defineModel<T>({ required: false })
</script>
<template>
@@ -47,8 +51,8 @@ const modelValue = defineModel<string | number>({ required: false })
:class="props.inputClass"
/>
<textarea
v-else
v-model="modelValue"
v-else-if="props.type !== 'number'"
v-model="modelValue as string | undefined"
:type="props.type"
:placeholder="props.placeholder"
:class="[
@@ -1,4 +1,8 @@
<script setup lang="ts">
<script
setup
lang="ts"
generic="InputType extends 'number' | string, T = InputType extends 'number' ? (number | undefined) : ((string | undefined))"
>
// Define button variants for better type safety and maintainability
type InputVariant = 'primary' | 'secondary' | 'primary-dimmed'
@@ -8,7 +12,7 @@ type InputTheme = 'default'
type InputSize = 'sm' | 'md' | 'lg'
const props = withDefaults(defineProps<{
type?: string
type?: InputType
variant?: InputVariant // Button style variant
size?: InputSize // Button size variant
theme?: InputTheme // Button theme
@@ -18,7 +22,7 @@ const props = withDefaults(defineProps<{
theme: 'default',
})
const modelValue = defineModel<string | number>({ required: false })
const modelValue = defineModel<T>({ required: false })
const variantClasses: Record<InputVariant, Record<InputTheme, {
default: string[]
+4 -1
View File
@@ -124,7 +124,7 @@ catalogs:
specifier: ^0.9.0
version: 0.9.0
nanoid:
specifier: ^5.1.6
specifier: 5.1.6
version: 5.1.6
ofetch:
specifier: ^1.5.1
@@ -1896,6 +1896,9 @@ importers:
listhen:
specifier: ^1.9.0
version: 1.9.0
nanoid:
specifier: 'catalog:'
version: 5.1.6
packages/server-sdk:
dependencies:
+1 -1
View File
@@ -62,7 +62,7 @@ catalog:
injeca: ^0.1.5
is-network-error: ^1.3.0
nano-staged: ^0.9.0
nanoid: ^5.1.6
nanoid: 5.1.6
ofetch: ^1.5.1
posthog-js: 1.306.1
splitpanes: ^4.0.4
@@ -157,13 +157,10 @@ export class DiscordAdapter {
// Handle output from AIRI system (IA response)
this.airiClient.onEvent('output:gen-ai:chat:message', async (event) => {
try {
const { message, discord } = event.data as {
message: { content: string }
discord?: { channelId: string }
}
if (discord?.channelId) {
const channel = await this.discordClient.channels.fetch(discord.channelId)
const message = (event.data as { message?: { content: string } }).message
const discordContext = (event.data)['gen-ai:chat'].input.data.discord
if (message?.content && discordContext?.channelId) {
const channel = await this.discordClient.channels.fetch(discordContext.channelId)
if (channel?.isTextBased() && 'send' in channel && typeof channel.send === 'function') {
await channel.send(message.content)
}