Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand Down
36 changes: 36 additions & 0 deletions contracts/tutorial-wam-data.schema.json
Original file line number Diff line number Diff line change
@@ -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"
]
}
35 changes: 16 additions & 19 deletions internal/tutorial/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() ||
Expand All @@ -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 != "" {
Expand All @@ -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
Expand Down
6 changes: 3 additions & 3 deletions internal/tutorial/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"},
Expand All @@ -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"},
Expand All @@ -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"},
Expand Down
37 changes: 37 additions & 0 deletions internal/tutorial/contracts.go
Original file line number Diff line number Diff line change
@@ -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
}
60 changes: 60 additions & 0 deletions internal/tutorial/contracts_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
52 changes: 24 additions & 28 deletions wam/README.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 4 additions & 2 deletions wam/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading
Loading