From 787c2d6d37392ac5aad25f61dbedca176a53318b Mon Sep 17 00:00:00 2001 From: Perry Byeongchan Park Date: Wed, 22 Jul 2026 22:07:46 +0900 Subject: [PATCH] feat: validate the shared WAM contract --- .github/workflows/ci.yml | 26 ++++++ Makefile | 9 +- README.md | 7 +- contracts/tutorial-wam-data.schema.json | 36 ++++++++ internal/tutorial/app.go | 35 ++++---- internal/tutorial/app_test.go | 6 +- internal/tutorial/contracts.go | 37 ++++++++ internal/tutorial/contracts_test.go | 60 +++++++++++++ wam/README.md | 52 ++++++------ wam/package.json | 6 +- wam/src/contracts.ts | 60 +++++++++++++ wam/src/hooks/useTutorialWamData.ts | 58 +++++++++++++ wam/src/pages/Send/Send.styled.ts | 8 -- wam/src/pages/Send/Send.tsx | 107 +++++++++++++++--------- wam/yarn.lock | 20 ++--- 15 files changed, 413 insertions(+), 114 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 contracts/tutorial-wam-data.schema.json create mode 100644 internal/tutorial/contracts.go create mode 100644 internal/tutorial/contracts_test.go create mode 100644 wam/src/contracts.ts create mode 100644 wam/src/hooks/useTutorialWamData.ts delete mode 100644 wam/src/pages/Send/Send.styled.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fb5a7f2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,26 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.x" + cache: true + - uses: actions/setup-node@v4 + with: + node-version: 20.11.0 + cache: yarn + cache-dependency-path: wam/yarn.lock + - run: corepack enable + - run: make init + - run: make test + - run: make build-wam + - run: make build-go diff --git a/Makefile b/Makefile index fb747c6..5a1ee00 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ TARGET_BIN_DIR ?= ${TARGET_DIR}/bin GOOS ?= $(shell go env GOOS) GOARCH ?= $(shell go env GOARCH) -.PHONY: init build-wam build-go build run dev test clean +.PHONY: init build-wam build-go build run dev test test-go test-wam clean init: go mod download @@ -27,8 +27,13 @@ run: dev: build run -test: +test: test-go test-wam + +test-go: go test ./... +test-wam: + cd ${PROJECT_PATH}/wam && corepack yarn format:check && corepack yarn lint && corepack yarn typecheck + clean: rm -rf ${TARGET_DIR} ${PROJECT_PATH}/wam/dist diff --git a/README.md b/README.md index cb31434..a281f89 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ design guidance: - typed app functions and generated JSON schemas - SDK-managed app/channel token caching and refresh - the SDK Gin server at `/functions/:version` -- a React WAM using `@channel.io/app-sdk-wam` `0.17.0` +- a React WAM using `@channel.io/app-sdk-wam` `0.17.2` +- a language-neutral JSON Schema checked against both Go DTOs and TypeScript WAM data +- redesigned Bezier components from `@channel.io/bezier-react/beta` Run the `/tutorial` desk command in a group chat to open a WAM. The WAM can send a team-chat message either through the app bot or as the current manager. Other chat types show an explicit unsupported @@ -137,7 +139,10 @@ a permission-failure case. Do not set `SKIP_SIGNATURE_VERIFICATION=true` outside cmd/main.go SDK server and extension auto-registration cmd/function_endpoint.go bare Function Endpoint compatibility route internal/tutorial/app.go command metadata and typed app functions +internal/tutorial/contracts.go Go types and names for the public WAM contract internal/tutorial/native_message.go one native transport adapter +contracts/ language-neutral WAM wire schema +wam/src/contracts.ts TypeScript view and runtime validation of that schema wam/src/pages/Send/Send.tsx WAM SDK hooks for app/native calls ``` diff --git a/contracts/tutorial-wam-data.schema.json b/contracts/tutorial-wam-data.schema.json new file mode 100644 index 0000000..948ba2b --- /dev/null +++ b/contracts/tutorial-wam-data.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://channel.io/schemas/tutorial-wam-data.json", + "title": "Tutorial WAM data", + "description": "Public wire data shared by the Go tutorial server and its React WAM.", + "x-channel-wam-name": "tutorial", + "x-channel-functions": { + "open": "tutorial.open", + "sendAsBot": "tutorial.sendAsBot", + "writeAsManager": "writeGroupMessageAsManager" + }, + "type": "object", + "additionalProperties": false, + "properties": { + "appId": { "type": "string" }, + "channelId": { "type": "string" }, + "managerId": { "type": "string" }, + "chatId": { "type": "string" }, + "chatType": { "type": "string" }, + "chatTitle": { "type": "string" }, + "rootMessageId": { "type": "string" }, + "broadcast": { "type": "boolean" }, + "message": { "type": "string" }, + "targetToken": { "type": "string" } + }, + "required": [ + "appId", + "channelId", + "managerId", + "chatId", + "chatType", + "chatTitle", + "broadcast", + "message" + ] +} diff --git a/internal/tutorial/app.go b/internal/tutorial/app.go index 2f8d6c9..365c573 100644 --- a/internal/tutorial/app.go +++ b/internal/tutorial/app.go @@ -60,18 +60,18 @@ func NewApp(cfg Config, sender MessageSender) (*appsdk.App, error) { app := appsdk.New(appsdk.Options{AppID: cfg.AppID, AppSecret: cfg.AppSecret}) if err := app.Use(command.Extension(). GetCommands(command.StaticCommands(&command.Config{ - Name: "tutorial", + Name: TutorialWAMName, Scope: command.ScopeDesk, Description: "Open the Channel App SDK tutorial WAM", - ActionFunctionName: "tutorial.open", + ActionFunctionName: TutorialOpenFunction, AlfMode: command.AlfModeDisable, EnabledByDefault: true, })). - Execute("tutorial.open", openTutorial(cfg.AppID, cfg.AppSecret))); err != nil { + Execute(TutorialOpenFunction, openTutorial(cfg.AppID, cfg.AppSecret))); err != nil { return nil, err } - if err := appsdk.Register(app, "tutorial.sendAsBot", + if err := appsdk.Register(app, TutorialSendBotFunction, func(ctx context.Context, fnCtx appsdk.Context, input *SendAsBotInput) (*EmptyOutput, error) { target, err := readTutorialTargetToken(input.TargetToken, cfg.AppSecret) if err != nil || target.ExpiresAt <= time.Now().Unix() || @@ -92,19 +92,16 @@ func NewApp(cfg Config, sender MessageSender) (*appsdk.App, error) { func openTutorial(appID, appSecret string) appsdk.TypedHandlerFunc[command.ExecuteRequest, command.ActionResult] { return func(_ context.Context, fnCtx appsdk.Context, input *command.ExecuteRequest) (*command.ActionResult, error) { - wamArgs := map[string]any{ - "managerId": fnCtx.Caller.ID, - "message": tutorialMessage, - } chat := input.GetChat() - if chat != nil { - wamArgs["chatId"] = chat.GetId() - wamArgs["chatType"] = chat.GetType() - } - if attributes := input.GetTrigger().GetAttributes(); attributes != nil { - wamArgs["chatTitle"] = attributes["chatTitle"] - wamArgs["rootMessageId"] = attributes["rootMessageId"] - wamArgs["broadcast"] = attributes["broadcast"] == "true" + triggerAttributes := input.GetTrigger().GetAttributes() + wamArgs := TutorialWAMArgs{ + ManagerID: fnCtx.Caller.ID, + ChatID: chat.GetId(), + ChatType: chat.GetType(), + ChatTitle: triggerAttributes["chatTitle"], + RootMessageID: triggerAttributes["rootMessageId"], + Broadcast: triggerAttributes["broadcast"] == "true", + Message: tutorialMessage, } if chat.GetType() == "group" && chat.GetId() != "" && fnCtx.Caller.Type == appsdk.CallerTypeManager && fnCtx.Caller.ID != "" { @@ -117,13 +114,13 @@ func openTutorial(appID, appSecret string) appsdk.TypedHandlerFunc[command.Execu if err != nil { return nil, err } - wamArgs["targetToken"] = targetToken + wamArgs.TargetToken = targetToken } attributes, err := structpb.NewStruct(map[string]any{ "appId": appID, - "name": "tutorial", - "wamArgs": wamArgs, + "name": TutorialWAMName, + "wamArgs": wamArgs.Map(), }) if err != nil { return nil, err diff --git a/internal/tutorial/app_test.go b/internal/tutorial/app_test.go index 35adf34..bb7a872 100644 --- a/internal/tutorial/app_test.go +++ b/internal/tutorial/app_test.go @@ -29,7 +29,7 @@ func TestTutorialAppUsesSDKFunctionRegistry(t *testing.T) { } open := app.HandleRequest(context.Background(), appsdk.FunctionRequest{ - Method: "tutorial.open", + Method: TutorialOpenFunction, Params: json.RawMessage(`{"chat":{"type":"group","id":"group"},"trigger":{"type":"command","attributes":{}},"input":{}}`), Context: appsdk.Context{ Caller: appsdk.Caller{Type: appsdk.CallerTypeManager, ID: "manager"}, @@ -53,7 +53,7 @@ func TestTutorialAppUsesSDKFunctionRegistry(t *testing.T) { } sent := app.HandleRequest(context.Background(), appsdk.FunctionRequest{ - Method: "tutorial.sendAsBot", + Method: TutorialSendBotFunction, Params: json.RawMessage(`{"targetToken":"` + targetToken + `"}`), Context: appsdk.Context{ Caller: appsdk.Caller{Type: appsdk.CallerTypeManager, ID: "manager"}, @@ -71,7 +71,7 @@ func TestTutorialAppUsesSDKFunctionRegistry(t *testing.T) { } rejected := app.HandleRequest(context.Background(), appsdk.FunctionRequest{ - Method: "tutorial.sendAsBot", + Method: TutorialSendBotFunction, Params: json.RawMessage(`{"targetToken":"` + targetToken + `tampered"}`), Context: appsdk.Context{ Caller: appsdk.Caller{Type: appsdk.CallerTypeManager, ID: "manager"}, diff --git a/internal/tutorial/contracts.go b/internal/tutorial/contracts.go new file mode 100644 index 0000000..fbff7fa --- /dev/null +++ b/internal/tutorial/contracts.go @@ -0,0 +1,37 @@ +package tutorial + +const ( + TutorialWAMName = "tutorial" + TutorialOpenFunction = "tutorial.open" + TutorialSendBotFunction = "tutorial.sendAsBot" + TutorialManagerFunction = "writeGroupMessageAsManager" +) + +type TutorialWAMArgs struct { + ManagerID string `json:"managerId"` + ChatID string `json:"chatId"` + ChatType string `json:"chatType"` + ChatTitle string `json:"chatTitle"` + RootMessageID string `json:"rootMessageId,omitempty"` + Broadcast bool `json:"broadcast"` + Message string `json:"message"` + TargetToken string `json:"targetToken,omitempty"` +} + +func (args TutorialWAMArgs) Map() map[string]any { + values := map[string]any{ + "managerId": args.ManagerID, + "chatId": args.ChatID, + "chatType": args.ChatType, + "chatTitle": args.ChatTitle, + "broadcast": args.Broadcast, + "message": args.Message, + } + if args.RootMessageID != "" { + values["rootMessageId"] = args.RootMessageID + } + if args.TargetToken != "" { + values["targetToken"] = args.TargetToken + } + return values +} diff --git a/internal/tutorial/contracts_test.go b/internal/tutorial/contracts_test.go new file mode 100644 index 0000000..0ade642 --- /dev/null +++ b/internal/tutorial/contracts_test.go @@ -0,0 +1,60 @@ +package tutorial + +import ( + "encoding/json" + "os" + "reflect" + "sort" + "strings" + "testing" +) + +func TestTutorialWAMArgsMatchPublicSchema(t *testing.T) { + body, err := os.ReadFile("../../contracts/tutorial-wam-data.schema.json") + if err != nil { + t.Fatal(err) + } + + var schema struct { + Properties map[string]json.RawMessage `json:"properties"` + Required []string `json:"required"` + WAMName string `json:"x-channel-wam-name"` + Functions map[string]string `json:"x-channel-functions"` + } + if err := json.Unmarshal(body, &schema); err != nil { + t.Fatal(err) + } + + contractFields := []string{"appId", "channelId"} + requiredFields := []string{"appId", "channelId"} + argsType := reflect.TypeOf(TutorialWAMArgs{}) + for index := 0; index < argsType.NumField(); index++ { + field := argsType.Field(index) + parts := strings.Split(field.Tag.Get("json"), ",") + contractFields = append(contractFields, parts[0]) + if len(parts) == 1 || parts[1] != "omitempty" { + requiredFields = append(requiredFields, parts[0]) + } + } + + schemaFields := make([]string, 0, len(schema.Properties)) + for name := range schema.Properties { + schemaFields = append(schemaFields, name) + } + sort.Strings(schemaFields) + sort.Strings(contractFields) + sort.Strings(schema.Required) + sort.Strings(requiredFields) + + if !reflect.DeepEqual(schemaFields, contractFields) { + t.Fatalf("schema fields %v do not match Go fields %v", schemaFields, contractFields) + } + if !reflect.DeepEqual(schema.Required, requiredFields) { + t.Fatalf("schema required fields %v do not match Go required fields %v", schema.Required, requiredFields) + } + if schema.WAMName != TutorialWAMName || schema.Functions["open"] != TutorialOpenFunction || + schema.Functions["sendAsBot"] != TutorialSendBotFunction || + schema.Functions["writeAsManager"] != TutorialManagerFunction { + t.Fatal("schema function names do not match Go contract constants") + } +} diff --git a/wam/README.md b/wam/README.md index 6a2c0c7..3e2c46c 100644 --- a/wam/README.md +++ b/wam/README.md @@ -1,41 +1,37 @@ # WAM -This frontend uses [`@channel.io/app-sdk-wam`](https://github.com/channel-io/cht-app-sdk/tree/main/ts/packages/wam) for the WAM bridge and [`@channel.io/app-sdk-wam-ui`](https://github.com/channel-io/cht-app-sdk/tree/main/ts/packages/wam-ui) for Channel-consistent UI patterns. Wrap the app with `WamProvider` and `WamThemeProvider`, then use SDK hooks and components for WAM data, app/native calls, sizing, closing, theming, and content-height synchronization. - -The example pins Bezier React `4.0.0-next.13` and Bezier Icons `0.60.0`. Bezier React 4 is still a prerelease, so keep the selected version explicit and check the [SDK WAM UI guide](https://github.com/channel-io/cht-app-sdk/blob/main/docs/reference/typescript/WAM-UI.md) before upgrading it. - -## Getting Started - -### Install Node and Yarn - -Install [`nvm`](https://github.com/nvm-sh/nvm) first, then install node via: - -```sh -$ nvm install -$ nvm use -``` - -Install Yarn via: - -```sh -$ corepack enable -$ corepack prepare yarn@stable --activate -``` +This React frontend uses +[`@channel.io/app-sdk-wam`](https://github.com/channel-io/cht-app-sdk/tree/main/ts/packages/wam) +for the WAM bridge and +[`@channel.io/app-sdk-wam-ui`](https://github.com/channel-io/cht-app-sdk/tree/main/ts/packages/wam-ui) +for WAM-specific theming, navigation, states, and content-height synchronization. Import +general-purpose UI components directly from `@channel.io/bezier-react/beta`. + +The language-neutral schema in `../contracts/tutorial-wam-data.schema.json` is the public wire +contract between the Go server and this WAM. Go parity tests verify its field and function names, +while `src/contracts.ts` validates host data before enabling function calls. Secrets, tokens used +to access Channel APIs, and server-only runtime types are not shared. + +The example pins Bezier React `4.0.0-next.13` and Bezier Icons `0.60.0`. Bezier React 4 is still a +prerelease, so keep the selected version explicit and check the +[SDK WAM UI guide](https://github.com/channel-io/cht-app-sdk/blob/main/docs/reference/typescript/WAM-UI.md) +before upgrading it. ## Development -Run dev server via: +Install dependencies and run the development server: ```sh -$ yarn dev +corepack yarn install --immutable +corepack yarn dev ``` -## Build - -Build WAM via: +Build and type-check the WAM: ```sh -$ yarn build +corepack yarn build +corepack yarn typecheck ``` -Build results will be in `dist/`. The tutorial server exposes that directory below `/resource/wam/tutorial`. +The WAM output is written to `dist/`. The Go tutorial server exposes that directory below +`/resource/wam/tutorial`. diff --git a/wam/package.json b/wam/package.json index e1c4035..b40846a 100644 --- a/wam/package.json +++ b/wam/package.json @@ -6,12 +6,14 @@ "scripts": { "dev": "vite", "build": "tsc && vite build", + "typecheck": "tsc --noEmit", "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0", + "format:check": "prettier --check \"src/**/*.{ts,tsx}\"", "preview": "vite preview" }, "dependencies": { - "@channel.io/app-sdk-wam": "0.17.0", - "@channel.io/app-sdk-wam-ui": "0.3.0", + "@channel.io/app-sdk-wam": "0.17.2", + "@channel.io/app-sdk-wam-ui": "0.4.0", "@channel.io/bezier-icons": "0.60.0", "@channel.io/bezier-react": "4.0.0-next.13", "react": "^18.2.0", diff --git a/wam/src/contracts.ts b/wam/src/contracts.ts new file mode 100644 index 0000000..32c0e4c --- /dev/null +++ b/wam/src/contracts.ts @@ -0,0 +1,60 @@ +import schema from '../../contracts/tutorial-wam-data.schema.json' + +export const TUTORIAL_WAM_NAME = schema['x-channel-wam-name'] +export const TUTORIAL_FUNCTIONS = schema['x-channel-functions'] + +export interface TutorialWamData { + appId: string + channelId: string + managerId: string + chatId: string + chatType: string + chatTitle: string + rootMessageId?: string + broadcast: boolean + message: string + targetToken?: string +} + +export type SendAsBotInput = { + targetToken: string + rootMessageId?: string + broadcast: boolean +} + +export type WriteGroupMessageAsManagerInput = { + channelId: string + groupId: string + rootMessageId?: string + broadcast: boolean + dto: { + plainText: string + managerId: string + } +} + +export function parseTutorialWamData(value: unknown): TutorialWamData { + if (typeof value !== 'object' || value === null) { + throw new Error('The host did not provide the expected tutorial WAM data.') + } + + const data = value as Record + for (const field of schema.required) { + if (data[field] === undefined) { + throw new Error( + 'The host did not provide the expected tutorial WAM data.' + ) + } + } + + for (const [field, definition] of Object.entries(schema.properties)) { + const fieldValue = data[field] + if (fieldValue !== undefined && typeof fieldValue !== definition.type) { + throw new Error( + 'The host did not provide the expected tutorial WAM data.' + ) + } + } + + return data as unknown as TutorialWamData +} diff --git a/wam/src/hooks/useTutorialWamData.ts b/wam/src/hooks/useTutorialWamData.ts new file mode 100644 index 0000000..cb84c2b --- /dev/null +++ b/wam/src/hooks/useTutorialWamData.ts @@ -0,0 +1,58 @@ +import { useMemo } from 'react' +import { useTypedWamData } from '@channel.io/app-sdk-wam' + +import { parseTutorialWamData, type TutorialWamData } from '../contracts' + +export interface TutorialWamDataResult { + data: TutorialWamData | null + error: Error | null +} + +export function useTutorialWamData(): TutorialWamDataResult { + const appId = useTypedWamData('appId') + const channelId = useTypedWamData('channelId') + const managerId = useTypedWamData('managerId') + const chatId = useTypedWamData('chatId') + const chatType = useTypedWamData('chatType') + const chatTitle = useTypedWamData('chatTitle') + const rootMessageId = useTypedWamData('rootMessageId') + const broadcast = useTypedWamData('broadcast') + const message = useTypedWamData('message') + const targetToken = useTypedWamData('targetToken') + + return useMemo(() => { + try { + return { + data: parseTutorialWamData({ + appId, + channelId, + managerId, + chatId, + chatType, + chatTitle, + rootMessageId, + broadcast, + message, + targetToken, + }), + error: null, + } + } catch (error) { + return { + data: null, + error: error instanceof Error ? error : new Error(String(error)), + } + } + }, [ + appId, + broadcast, + channelId, + chatId, + chatTitle, + chatType, + managerId, + message, + rootMessageId, + targetToken, + ]) +} diff --git a/wam/src/pages/Send/Send.styled.ts b/wam/src/pages/Send/Send.styled.ts deleted file mode 100644 index c94a8db..0000000 --- a/wam/src/pages/Send/Send.styled.ts +++ /dev/null @@ -1,8 +0,0 @@ -import styled from 'styled-components' - -export const CenterTextWrapper = styled.div` - display: flex; - align-items: center; - justify-content: center; - gap: 2px; -` diff --git a/wam/src/pages/Send/Send.tsx b/wam/src/pages/Send/Send.tsx index d99c1bc..f9cdac9 100644 --- a/wam/src/pages/Send/Send.tsx +++ b/wam/src/pages/Send/Send.tsx @@ -1,43 +1,48 @@ -import { useEffect, useCallback, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useCallFunction, useNativeFunction, - useTypedWamData, useWamClose, useWamSize, } from '@channel.io/app-sdk-wam' import { - VStack, - HStack, Button, - Text, - Icon, ButtonGroup, -} from '@channel.io/bezier-react' + HStack, + Icon, + Text, + VStack, +} from '@channel.io/bezier-react/beta' import { SendIcon } from '@channel.io/bezier-icons' import { InlineBanner } from '@channel.io/app-sdk-wam-ui' -import * as Styled from './Send.styled' +import { + TUTORIAL_FUNCTIONS, + type SendAsBotInput, + type WriteGroupMessageAsManagerInput, +} from '../../contracts' +import { useTutorialWamData } from '../../hooks/useTutorialWamData' function Send() { const { setSize } = useWamSize() const { close } = useWamClose() const [errorMessage, setErrorMessage] = useState('') + const { data: wamData, error: wamDataError } = useTutorialWamData() useEffect(() => { setSize({ width: 390, height: 220 }) }, [setSize]) - const chatTitle = useTypedWamData('chatTitle') ?? '' - const appId = useTypedWamData('appId') ?? '' - const channelId = useTypedWamData('channelId') ?? '' - const managerId = useTypedWamData('managerId') ?? '' - const message = String(useTypedWamData('message') ?? '') - const chatId = useTypedWamData('chatId') ?? '' - const chatType = useTypedWamData('chatType') ?? '' - const broadcast = useTypedWamData('broadcast') ?? false - const rootMessageId = useTypedWamData('rootMessageId') - const targetToken = String(useTypedWamData('targetToken') ?? '') + const chatTitle = wamData?.chatTitle ?? '' + const appId = wamData?.appId ?? '' + const channelId = wamData?.channelId ?? '' + const managerId = wamData?.managerId ?? '' + const message = wamData?.message ?? '' + const chatId = wamData?.chatId ?? '' + const chatType = wamData?.chatType ?? '' + const broadcast = wamData?.broadcast ?? false + const rootMessageId = wamData?.rootMessageId + const targetToken = wamData?.targetToken ?? '' const { call: sendAsBot, @@ -45,28 +50,36 @@ function Send() { error: botError, } = useCallFunction({ appId, - name: 'tutorial.sendAsBot', + name: TUTORIAL_FUNCTIONS.sendAsBot, }) const { call: sendAsManager, loading: managerLoading, error: managerError, - } = useNativeFunction({ name: 'writeGroupMessageAsManager' }) + } = useNativeFunction({ name: TUTORIAL_FUNCTIONS.writeAsManager }) const isSending = botLoading || managerLoading const statusMessage = errorMessage || - (botError || managerError - ? 'The message could not be sent. Check the app permissions and try again.' - : chatType === 'group' && !targetToken - ? 'The bot target is unavailable. Close and reopen the command.' - : chatType && chatType !== 'group' - ? 'This tutorial sends messages only from a group chat.' - : '') + (wamDataError + ? wamDataError.message + : botError || managerError + ? 'The message could not be sent. Check the app permissions and try again.' + : chatType === 'group' && !targetToken + ? 'The bot target is unavailable. Close and reopen the command.' + : chatType && chatType !== 'group' + ? 'This tutorial sends messages only from a group chat.' + : '') const handleSend = useCallback( async (sender: 'bot' | 'manager'): Promise => { setErrorMessage('') + if (!wamData) { + setErrorMessage( + 'The host did not provide the expected tutorial WAM data.' + ) + return + } if (chatType !== 'group') { setErrorMessage('This tutorial sends messages only from a group chat.') return @@ -75,15 +88,16 @@ function Send() { try { switch (sender) { case 'bot': { - await sendAsBot({ + const input: SendAsBotInput = { targetToken, broadcast, rootMessageId, - }) + } + await sendAsBot(input) break } case 'manager': { - await sendAsManager({ + const input: WriteGroupMessageAsManagerInput = { channelId, groupId: chatId, rootMessageId, @@ -92,7 +106,8 @@ function Send() { plainText: message, managerId, }, - }) + } + await sendAsManager(input) break } } @@ -115,6 +130,7 @@ function Send() { sendAsBot, sendAsManager, targetToken, + wamData, ] ) @@ -123,27 +139,32 @@ function Send() {