feat: build initial clickclack app

This commit is contained in:
Peter Steinberger 2026-05-08 05:36:16 +01:00
parent 66cb07fb1a
commit 5cea1a52cb
No known key found for this signature in database
75 changed files with 11617 additions and 19 deletions

12
.gitignore vendored Normal file
View File

@ -0,0 +1,12 @@
node_modules/
apps/web/dist/
packages/sdk-ts/dist/
data/
coverage.out
coverage.txt
test-results/
playwright-report/
*.db
*.db-shm
*.db-wal
.DS_Store

3
.oxfmtrc.json Normal file
View File

@ -0,0 +1,3 @@
{
"lineWidth": 120
}

31
Dockerfile Normal file
View File

@ -0,0 +1,31 @@
FROM node:25-alpine AS web
WORKDIR /src
RUN corepack enable
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY apps/web/package.json apps/web/package.json
COPY packages/protocol/package.json packages/protocol/package.json
COPY packages/sdk-ts/package.json packages/sdk-ts/package.json
RUN pnpm install --frozen-lockfile
COPY apps apps
COPY packages packages
RUN pnpm build
FROM golang:1.26-alpine AS api
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY apps/api apps/api
COPY infra infra
COPY --from=web /src/apps/api/internal/webassets/dist apps/api/internal/webassets/dist
RUN go build -o /out/clickclack ./apps/api/cmd/clickclack
FROM alpine:3.23
RUN adduser -D -H clickclack
WORKDIR /app
COPY --from=api /out/clickclack /usr/local/bin/clickclack
RUN mkdir -p /app/data && chown -R clickclack:clickclack /app
USER clickclack
EXPOSE 8080
VOLUME ["/app/data"]
ENTRYPOINT ["clickclack"]
CMD ["serve", "--addr", ":8080", "--data", "/app/data"]

View File

@ -3,3 +3,34 @@
Self-hostable chat with Slack-style threads, Discord-ish warmth, and light crustacean seasoning.
See [SPEC.md](SPEC.md) for the initial product and architecture plan.
## Development
```sh
pnpm install
pnpm build
go run ./apps/api/cmd/clickclack serve
```
Open http://localhost:8080.
Useful commands:
```sh
go test ./...
pnpm -r typecheck
pnpm lint
pnpm coverage
pnpm test:e2e
pnpm build
go run ./apps/api/cmd/clickclack admin bootstrap --name "Peter" --email steipete@gmail.com
go run ./apps/api/cmd/clickclack admin magic-link create --email steipete@gmail.com --name "Peter"
go run ./apps/api/cmd/clickclack backup --out ./data/backup.db
go run ./apps/api/cmd/clickclack export --out ./data/export.json
pnpm --filter @clickclack/example-bot start
```
TypeScript uses `tsgo` from `@typescript/native-preview`; formatting/linting use `oxfmt` and `oxlint`.
Local auth supports dev fallback, `X-ClickClack-User`, bearer session tokens, and CLI-generated magic-link tokens.
Optional GitHub OAuth is enabled with `CLICKCLACK_PUBLIC_URL`, `CLICKCLACK_GITHUB_CLIENT_ID`, and `CLICKCLACK_GITHUB_CLIENT_SECRET`.
The bot example in `examples/bot-ts` uses the framework-neutral SDK and the same auth headers as the web app.

122
SPEC.md
View File

@ -11,6 +11,18 @@ ClickClack is a self-hostable, API-first chat app for internal testing, small te
- Ship a TypeScript SDK for bots, integrations, and community tooling.
- Feel playful and memorable without sacrificing dense, practical chat workflows.
## Locked V1 Decisions
- First implementation target: realtime channel chat plus Slack-style threads, not skeleton-only.
- Auth starts CLI-manageable: local owner/user bootstrap and invite/token management from `clickclack admin ...`.
- GitHub OAuth is optional V1, after local auth is usable.
- Frontend is Svelte 5 + Vite SPA. No SvelteKit server layer.
- API contract is OpenAPI-first, with `packages/protocol/openapi.yaml` as the source of truth.
- IDs use ULID-style sortable text IDs with semantic prefixes such as `usr_`, `wsp_`, `chn_`, `msg_`, `evt_`.
- Message body format starts as Markdown. Clients render a safe Markdown subset.
- Search, uploads, and DMs are V1 product scope, but come after the realtime channel/thread vertical slice is working.
- Monorepo layout is the canonical repo shape.
## Non-Goals For V1
- Voice/video rooms.
@ -72,15 +84,22 @@ The first useful build should support:
- Create/select workspace.
- Create/select channel.
- Send text message.
- Send Markdown text message.
- Realtime message delivery over WebSocket.
- Open message thread in right pane.
- Send thread reply.
- Persist everything in SQLite.
- Reload/reconnect and recover state.
- Basic dev/local auth.
- CLI-manageable local auth/bootstrap.
- Embedded web app served by Go.
After that vertical slice is stable, V1 expands to:
- Direct messages.
- SQLite FTS5 message search.
- Local file uploads and message attachments.
- GitHub OAuth as an optional login path.
## Architecture
```text
@ -127,7 +146,7 @@ Future hosted runtime:
- Postgres later: `pgx`.
- Queries: start handwritten or `sqlc` once schema settles.
- Migrations: embedded SQL migrations with a tiny internal runner, or `goose` if the runner grows.
- IDs: UUIDv7 or ULID.
- IDs: ULID-style sortable text IDs with type prefixes.
### CLI
@ -140,10 +159,19 @@ clickclack serve
clickclack migrate
--db sqlite://./data/clickclack.db
clickclack admin invite
clickclack admin bootstrap
--name "Peter"
--email steipete@gmail.com
clickclack admin user create
--name "Ari"
--email ari@example.com
clickclack admin invite create
--workspace wsp_...
```
Default `clickclack serve` should be enough for local use.
Default `clickclack serve` should be enough for local development. Production-like local use should bootstrap an owner through the CLI before exposing the instance.
## Frontend
@ -188,9 +216,12 @@ GET /api/workspaces/{workspace_id}
GET /api/workspaces/{workspace_id}/channels
POST /api/workspaces/{workspace_id}/channels
PATCH /api/channels/{channel_id}
GET /api/channels/{channel_id}/messages?before=&after_seq=&limit=
POST /api/channels/{channel_id}/messages
PATCH /api/messages/{message_id}
DELETE /api/messages/{message_id}
GET /api/messages/{message_id}/thread
POST /api/messages/{message_id}/thread/replies
@ -199,7 +230,18 @@ POST /api/messages/{message_id}/reactions
DELETE /api/messages/{message_id}/reactions/{emoji}
GET /api/realtime/events?after_cursor=
POST /api/realtime/ephemeral
GET /api/realtime/ws
GET /api/search?workspace_id=&q=&limit=
POST /api/uploads
GET /api/uploads/{upload_id}
GET /api/dms
POST /api/dms
GET /api/dms/{conversation_id}/messages?before=&after_seq=&limit=
POST /api/dms/{conversation_id}/messages
```
## Realtime
@ -337,6 +379,31 @@ events
type
payload_json
created_at
uploads
id
workspace_id
owner_id
filename
content_type
byte_size
storage_path
created_at
message_attachments
message_id
upload_id
created_at
direct_conversations
id
workspace_id
created_at
direct_conversation_members
conversation_id
user_id
created_at
```
Thread rules:
@ -374,13 +441,17 @@ data/
V0:
- Dev/local auth for quick testing.
- Owner bootstrap on first run.
- CLI owner bootstrap.
- CLI user/invite management.
- Dev/local auth for quick testing, gated to local/dev mode.
- CLI-generated magic-link tokens.
- Bearer session tokens and HTTP-only cookie sessions.
V1:
- Magic links.
- GitHub OAuth.
- Magic-link token issuance and consume flow, with CLI/local delivery first.
- GitHub OAuth as optional login, enabled via self-host config.
- SMTP or provider-backed email delivery later, once deployment mail settings are known.
- Optional local email/password only if needed for fully offline/self-hosted deployments.
Auth principles:
@ -401,7 +472,7 @@ packages/sdk-ts
Layering:
- Generated OpenAPI client.
- Generated OpenAPI types.
- Friendly wrapper.
- WebSocket/event subscription helper.
@ -499,26 +570,39 @@ top: channel title, members, search
- Thread pane.
- Thread reply counts and last reply state.
### M4: Self-Host Polish
### M4: Search, Uploads, DMs
- SQLite FTS5 message search.
- Local upload storage.
- Message attachments.
- Direct message conversations.
### M5: Self-Host Polish
- First-run owner setup.
- CLI-generated magic-link auth.
- Config file/env.
- Local upload storage.
- Docker image.
- Backups/export.
### M5: SDK
### M6: SDK And Integrations
- OpenAPI generation.
- TypeScript SDK.
- Incoming webhooks.
- Basic bot example.
## Answered Questions
- Setup starts with CLI owner bootstrap. A setup UI can be added later.
- Markdown is the initial rich text format.
- DMs are V1 scope, after channel chat and threads.
- Search starts with SQLite FTS5.
- Uploads are V1 scope, after core chat is solid.
- OpenAPI remains source of truth from the first scaffold.
- TypeScript compilation uses `tsgo`; lint/format use `oxlint` and `oxfmt`.
- GitHub OAuth ships in V1 as an optional configured auth provider.
## Open Questions
- Should `clickclack serve` expose setup UI on first run or require CLI owner bootstrap?
- How much markdown/rich text in V1?
- Do we need DMs in V1, or only channels and threads?
- Should search start with SQLite FTS5?
- Should uploads exist in V1 or wait until core chat is solid?
- Which generated OpenAPI toolchain is least annoying for Go plus TypeScript?
- Whether to add generated Go request/response validation from OpenAPI in V1 or keep the first backend on hand-written handlers.

View File

@ -0,0 +1,321 @@
package main
import (
"context"
"flag"
"fmt"
"log"
"os"
"os/signal"
"path/filepath"
"strings"
"github.com/openclaw/clickclack/apps/api/internal/config"
"github.com/openclaw/clickclack/apps/api/internal/httpapi"
"github.com/openclaw/clickclack/apps/api/internal/realtime"
"github.com/openclaw/clickclack/apps/api/internal/store"
sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite"
)
func main() {
if err := run(); err != nil {
log.Fatal(err)
}
}
func run() error {
cmd := "serve"
if len(os.Args) > 1 {
cmd = os.Args[1]
}
switch cmd {
case "serve":
return serve(os.Args[2:])
case "migrate":
return migrate(os.Args[2:])
case "admin":
return admin(os.Args[2:])
case "backup":
return backup(os.Args[2:])
case "export":
return exportData(os.Args[2:])
default:
return fmt.Errorf("unknown command %q", cmd)
}
}
func serve(args []string) error {
flags := flag.NewFlagSet("serve", flag.ExitOnError)
flags.String("addr", ":8080", "HTTP listen address")
flags.String("data", "./data", "data directory")
flags.String("db", "", "database URL")
configPath := flags.String("config", "", "config file")
devBootstrap := flags.Bool("dev-bootstrap", true, "create a local owner/workspace/channel if no user exists")
if err := flags.Parse(args); err != nil {
return err
}
cfg, err := config.Load(*configPath)
if err != nil {
return err
}
applyFlagOverrides(flags, &cfg)
url := resolveDB(cfg.Data, cfg.DB)
if err := ensureDirs(cfg.Data); err != nil {
return err
}
st, err := sqlitestore.Open(url)
if err != nil {
return err
}
defer st.Close()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt)
defer stop()
if err := st.Migrate(ctx); err != nil {
return err
}
if *devBootstrap {
user, err := st.EnsureBootstrap(ctx, "Local Captain", "local@clickclack.chat")
if err != nil {
return err
}
log.Printf("dev auth user: %s (%s)", user.DisplayName, user.ID)
}
log.Printf("ClickClack listening on %s", displayURL(cfg.Addr))
server := httpapi.New(st, realtime.NewHub(), httpapi.Options{
UploadDir: filepath.Join(cfg.Data, "uploads"),
GitHubOAuth: httpapi.GitHubOAuthConfig{
ClientID: cfg.GitHubClientID,
ClientSecret: cfg.GitHubClientSecret,
PublicURL: cfg.PublicURL,
},
})
return httpapi.ListenAndServe(ctx, cfg.Addr, server.Handler())
}
func migrate(args []string) error {
flags := flag.NewFlagSet("migrate", flag.ExitOnError)
data := flags.String("data", "./data", "data directory")
dbURL := flags.String("db", "", "database URL")
if err := flags.Parse(args); err != nil {
return err
}
if err := ensureDirs(*data); err != nil {
return err
}
st, err := sqlitestore.Open(resolveDB(*data, *dbURL))
if err != nil {
return err
}
defer st.Close()
return st.Migrate(context.Background())
}
func admin(args []string) error {
if len(args) == 0 {
return fmt.Errorf("admin requires a subcommand")
}
switch args[0] {
case "bootstrap":
flags := flag.NewFlagSet("admin bootstrap", flag.ExitOnError)
data := flags.String("data", "./data", "data directory")
dbURL := flags.String("db", "", "database URL")
name := flags.String("name", "Owner", "owner display name")
email := flags.String("email", "", "owner email")
if err := flags.Parse(args[1:]); err != nil {
return err
}
if err := ensureDirs(*data); err != nil {
return err
}
st, err := sqlitestore.Open(resolveDB(*data, *dbURL))
if err != nil {
return err
}
defer st.Close()
ctx := context.Background()
if err := st.Migrate(ctx); err != nil {
return err
}
user, err := st.EnsureBootstrap(ctx, *name, *email)
if err != nil {
return err
}
fmt.Printf("%s\n", user.ID)
return nil
case "user":
if len(args) < 2 || args[1] != "create" {
return fmt.Errorf("usage: clickclack admin user create --name NAME --email EMAIL")
}
flags := flag.NewFlagSet("admin user create", flag.ExitOnError)
data := flags.String("data", "./data", "data directory")
dbURL := flags.String("db", "", "database URL")
name := flags.String("name", "Local User", "display name")
email := flags.String("email", "", "email")
workspaceID := flags.String("workspace", "", "workspace id to join as member")
if err := flags.Parse(args[2:]); err != nil {
return err
}
st, err := sqlitestore.Open(resolveDB(*data, *dbURL))
if err != nil {
return err
}
defer st.Close()
if err := st.Migrate(context.Background()); err != nil {
return err
}
user, err := st.CreateUser(context.Background(), store.CreateUserInput{DisplayName: *name, Email: *email})
if err != nil {
return err
}
if *workspaceID != "" {
if err := st.AddWorkspaceMember(context.Background(), *workspaceID, user.ID, "member"); err != nil {
return err
}
}
fmt.Printf("%s\n", user.ID)
return nil
case "invite":
if len(args) < 2 || args[1] != "create" {
return fmt.Errorf("usage: clickclack admin invite create --workspace WORKSPACE_ID")
}
flags := flag.NewFlagSet("admin invite create", flag.ExitOnError)
data := flags.String("data", "./data", "data directory")
dbURL := flags.String("db", "", "database URL")
workspaceID := flags.String("workspace", "", "workspace id")
if err := flags.Parse(args[2:]); err != nil {
return err
}
if *workspaceID == "" {
return fmt.Errorf("--workspace is required")
}
st, err := sqlitestore.Open(resolveDB(*data, *dbURL))
if err != nil {
return err
}
defer st.Close()
ctx := context.Background()
if err := st.Migrate(ctx); err != nil {
return err
}
user, err := st.FirstUser(ctx)
if err != nil {
return err
}
invite, err := st.CreateInvite(ctx, *workspaceID, user.ID)
if err != nil {
return err
}
fmt.Printf("%s\n", invite.Token)
return nil
case "magic-link":
if len(args) < 2 || args[1] != "create" {
return fmt.Errorf("usage: clickclack admin magic-link create --email EMAIL [--name NAME]")
}
flags := flag.NewFlagSet("admin magic-link create", flag.ExitOnError)
data := flags.String("data", "./data", "data directory")
dbURL := flags.String("db", "", "database URL")
email := flags.String("email", "", "email")
name := flags.String("name", "", "display name")
if err := flags.Parse(args[2:]); err != nil {
return err
}
st, err := sqlitestore.Open(resolveDB(*data, *dbURL))
if err != nil {
return err
}
defer st.Close()
ctx := context.Background()
if err := st.Migrate(ctx); err != nil {
return err
}
link, err := st.CreateMagicLink(ctx, *email, *name)
if err != nil {
return err
}
fmt.Printf("%s\n", link.Token)
return nil
default:
return fmt.Errorf("unknown admin subcommand %q", args[0])
}
}
func backup(args []string) error {
flags := flag.NewFlagSet("backup", flag.ExitOnError)
data := flags.String("data", "./data", "data directory")
dbURL := flags.String("db", "", "database URL")
out := flags.String("out", "", "backup SQLite path")
if err := flags.Parse(args); err != nil {
return err
}
if *out == "" {
return fmt.Errorf("--out is required")
}
st, err := sqlitestore.Open(resolveDB(*data, *dbURL))
if err != nil {
return err
}
defer st.Close()
return st.Backup(context.Background(), *out)
}
func exportData(args []string) error {
flags := flag.NewFlagSet("export", flag.ExitOnError)
data := flags.String("data", "./data", "data directory")
dbURL := flags.String("db", "", "database URL")
out := flags.String("out", "-", "JSON output path or '-'")
if err := flags.Parse(args); err != nil {
return err
}
st, err := sqlitestore.Open(resolveDB(*data, *dbURL))
if err != nil {
return err
}
defer st.Close()
var writer *os.File
if *out == "-" {
writer = os.Stdout
} else {
writer, err = os.Create(*out)
if err != nil {
return err
}
defer writer.Close()
}
return st.ExportJSON(context.Background(), writer)
}
func resolveDB(data, dbURL string) string {
if dbURL != "" {
return dbURL
}
return "sqlite://" + filepath.Join(data, "clickclack.db")
}
func applyFlagOverrides(flags *flag.FlagSet, cfg *config.Config) {
flags.Visit(func(f *flag.Flag) {
switch f.Name {
case "addr":
cfg.Addr = f.Value.String()
case "data":
cfg.Data = f.Value.String()
case "db":
cfg.DB = f.Value.String()
}
})
}
func ensureDirs(data string) error {
for _, dir := range []string{data, filepath.Join(data, "uploads"), filepath.Join(data, "logs")} {
if err := os.MkdirAll(dir, 0o755); err != nil {
return err
}
}
return nil
}
func displayURL(addr string) string {
if strings.HasPrefix(addr, ":") {
return "http://localhost" + addr
}
return "http://" + addr
}

View File

@ -0,0 +1,58 @@
package config
import (
"encoding/json"
"os"
)
type Config struct {
Addr string `json:"addr"`
Data string `json:"data"`
DB string `json:"db"`
PublicURL string `json:"public_url"`
GitHubClientID string `json:"github_client_id"`
GitHubClientSecret string `json:"github_client_secret"`
}
func Defaults() Config {
return Config{Addr: ":8080", Data: "./data"}
}
func Load(path string) (Config, error) {
cfg := Defaults()
if env := os.Getenv("CLICKCLACK_ADDR"); env != "" {
cfg.Addr = env
}
if env := os.Getenv("CLICKCLACK_DATA"); env != "" {
cfg.Data = env
}
if env := os.Getenv("CLICKCLACK_DB"); env != "" {
cfg.DB = env
}
if env := os.Getenv("CLICKCLACK_PUBLIC_URL"); env != "" {
cfg.PublicURL = env
}
if env := os.Getenv("CLICKCLACK_GITHUB_CLIENT_ID"); env != "" {
cfg.GitHubClientID = env
}
if env := os.Getenv("CLICKCLACK_GITHUB_CLIENT_SECRET"); env != "" {
cfg.GitHubClientSecret = env
}
if path == "" {
return cfg, nil
}
body, err := os.ReadFile(path)
if err != nil {
return Config{}, err
}
if err := json.Unmarshal(body, &cfg); err != nil {
return Config{}, err
}
if cfg.Addr == "" {
cfg.Addr = ":8080"
}
if cfg.Data == "" {
cfg.Data = "./data"
}
return cfg, nil
}

View File

@ -0,0 +1,63 @@
package config
import (
"os"
"path/filepath"
"testing"
)
func TestLoadDefaultsEnvAndFile(t *testing.T) {
t.Setenv("CLICKCLACK_ADDR", ":9000")
t.Setenv("CLICKCLACK_DATA", "/tmp/clickclack")
t.Setenv("CLICKCLACK_DB", "sqlite:///tmp/clickclack.db")
t.Setenv("CLICKCLACK_PUBLIC_URL", "https://clickclack.test")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_ID", "client")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_SECRET", "secret")
cfg, err := Load("")
if err != nil {
t.Fatal(err)
}
if cfg.Addr != ":9000" || cfg.Data != "/tmp/clickclack" || cfg.DB != "sqlite:///tmp/clickclack.db" || cfg.PublicURL != "https://clickclack.test" || cfg.GitHubClientID != "client" || cfg.GitHubClientSecret != "secret" {
t.Fatalf("unexpected env config: %#v", cfg)
}
path := filepath.Join(t.TempDir(), "config.json")
if err := os.WriteFile(path, []byte(`{"addr":":7000","data":"/data"}`), 0o644); err != nil {
t.Fatal(err)
}
cfg, err = Load(path)
if err != nil {
t.Fatal(err)
}
if cfg.Addr != ":7000" || cfg.Data != "/data" {
t.Fatalf("unexpected file config: %#v", cfg)
}
t.Setenv("CLICKCLACK_ADDR", "")
t.Setenv("CLICKCLACK_DATA", "")
t.Setenv("CLICKCLACK_DB", "")
t.Setenv("CLICKCLACK_PUBLIC_URL", "")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_ID", "")
t.Setenv("CLICKCLACK_GITHUB_CLIENT_SECRET", "")
emptyPath := filepath.Join(t.TempDir(), "empty.json")
if err := os.WriteFile(emptyPath, []byte(`{}`), 0o644); err != nil {
t.Fatal(err)
}
cfg, err = Load(emptyPath)
if err != nil {
t.Fatal(err)
}
if cfg.Addr != ":8080" || cfg.Data != "./data" {
t.Fatalf("unexpected fallback config: %#v", cfg)
}
if _, err := Load(filepath.Join(t.TempDir(), "missing.json")); err == nil {
t.Fatal("expected missing config error")
}
badPath := filepath.Join(t.TempDir(), "bad.json")
if err := os.WriteFile(badPath, []byte(`{`), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(badPath); err == nil {
t.Fatal("expected bad json error")
}
}

View File

@ -0,0 +1,33 @@
package httpapi
import "net/http"
func (s *Server) requestMagicLink(w http.ResponseWriter, r *http.Request) {
var body struct {
Email string `json:"email"`
DisplayName string `json:"display_name"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
link, err := s.store.CreateMagicLink(r.Context(), body.Email, body.DisplayName)
writeResultStatus(w, http.StatusCreated, map[string]any{"magic_link": link, "token": link.Token}, err)
}
func (s *Server) consumeMagicLink(w http.ResponseWriter, r *http.Request) {
var body struct {
Token string `json:"token"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
user, session, err := s.store.ConsumeMagicLink(r.Context(), body.Token)
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
setSessionCookie(w, session)
writeJSON(w, http.StatusOK, map[string]any{"user": user, "session": session, "token": session.Token})
}

View File

@ -0,0 +1,296 @@
package httpapi
import (
"bytes"
"context"
"io"
"io/fs"
"mime/multipart"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"time"
"github.com/openclaw/clickclack/apps/api/internal/realtime"
"github.com/openclaw/clickclack/apps/api/internal/store"
sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite"
"github.com/openclaw/clickclack/apps/api/internal/webassets"
)
func TestHTTPUnauthorizedRoutes(t *testing.T) {
t.Parallel()
st := newEmptyHTTPStore(t)
server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(t.TempDir(), "uploads")}).Handler())
t.Cleanup(server.Close)
cases := []struct {
method string
path string
body string
}{
{http.MethodGet, "/api/me", ""},
{http.MethodGet, "/api/workspaces", ""},
{http.MethodPost, "/api/workspaces", `{"name":"x"}`},
{http.MethodGet, "/api/workspaces/wsp_missing", ""},
{http.MethodGet, "/api/workspaces/wsp_missing/channels", ""},
{http.MethodPost, "/api/workspaces/wsp_missing/channels", `{"name":"x"}`},
{http.MethodGet, "/api/channels/chn_missing/messages", ""},
{http.MethodPost, "/api/channels/chn_missing/messages", `{"body":"x"}`},
{http.MethodGet, "/api/messages/msg_missing/thread", ""},
{http.MethodPost, "/api/messages/msg_missing/thread/replies", `{"body":"x"}`},
{http.MethodPost, "/api/messages/msg_missing/reactions", `{"emoji":"x"}`},
{http.MethodDelete, "/api/messages/msg_missing/reactions/x", ""},
{http.MethodGet, "/api/realtime/events?workspace_id=wsp_missing", ""},
{http.MethodGet, "/api/realtime/ws?workspace_id=wsp_missing", ""},
{http.MethodGet, "/api/search?workspace_id=wsp_missing&q=x", ""},
{http.MethodPost, "/api/uploads", ""},
{http.MethodGet, "/api/uploads/upl_missing", ""},
{http.MethodPost, "/api/messages/msg_missing/attachments", `{"upload_id":"upl_missing"}`},
{http.MethodGet, "/api/dms?workspace_id=wsp_missing", ""},
{http.MethodPost, "/api/dms", `{"workspace_id":"wsp_missing","member_ids":[]}`},
{http.MethodGet, "/api/dms/dm_missing/messages", ""},
{http.MethodPost, "/api/dms/dm_missing/messages", `{"body":"x"}`},
{http.MethodPost, "/api/hooks/mattermost/chn_missing", `{"text":"x"}`},
}
for _, tc := range cases {
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
req, err := http.NewRequest(tc.method, server.URL+tc.path, strings.NewReader(tc.body))
if err != nil {
t.Fatal(err)
}
if tc.body != "" {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusUnauthorized {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected unauthorized, got %s %s", resp.Status, string(body))
}
})
}
}
func TestHTTPUploadNotConfiguredAndCookieAuth(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newHTTPStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
link, err := st.CreateMagicLink(ctx, "cookie@example.com", "Cookie User")
if err != nil {
t.Fatal(err)
}
_, session, err := st.ConsumeMagicLink(ctx, link.Token)
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(New(st, realtime.NewHub(), Options{}).Handler())
t.Cleanup(server.Close)
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(&http.Cookie{Name: "cc_session", Value: session.Token})
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected cookie auth, got %s", resp.Status)
}
resp.Body.Close()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("workspace_id", "unused"); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
req, err = http.NewRequest(http.MethodPost, server.URL+"/api/uploads", &body)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", writer.FormDataContentType())
req.Header.Set("X-ClickClack-User", owner.ID)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusInternalServerError {
t.Fatalf("expected upload config error, got %s", resp.Status)
}
}
func TestHTTPMalformedJSONRoutes(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "root"})
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)
paths := []string{
"/api/auth/magic/request",
"/api/auth/magic/consume",
"/api/workspaces",
"/api/workspaces/" + workspaces[0].ID + "/channels",
"/api/channels/" + channels[0].ID + "/messages",
"/api/messages/" + root.ID + "/thread/replies",
"/api/messages/" + root.ID + "/reactions",
"/api/messages/" + root.ID + "/attachments",
"/api/dms",
"/api/hooks/mattermost/" + channels[0].ID,
}
for _, path := range paths {
t.Run(path, func(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, server.URL+path, strings.NewReader("{"))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected bad request, got %s %s", resp.Status, string(body))
}
})
}
other, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Other", Email: "other@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspaces[0].ID, other.ID, "member"); err != nil {
t.Fatal(err)
}
dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspaces[0].ID, UserID: owner.ID, MemberIDs: []string{other.ID}})
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPost, server.URL+"/api/dms/"+dm.ID+"/messages", strings.NewReader("{"))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected malformed dm message error, got %s", resp.Status)
}
resp.Body.Close()
resp, err = http.PostForm(server.URL+"/api/hooks/slash/"+channels[0].ID, nil)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusBadRequest {
t.Fatalf("expected empty slash command error, got %s", resp.Status)
}
}
func TestHTTPServesEmbeddedAsset(t *testing.T) {
t.Parallel()
st := newEmptyHTTPStore(t)
server := httptest.NewServer(New(st, realtime.NewHub(), Options{}).Handler())
t.Cleanup(server.Close)
assets, err := fs.ReadDir(webassets.Dist, "dist/assets")
if err != nil {
t.Fatal(err)
}
if len(assets) == 0 {
t.Fatal("expected embedded assets")
}
resp, err := http.Get(server.URL + "/assets/" + assets[0].Name())
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected asset response, got %s", resp.Status)
}
}
func TestListenAndServeStopsWithContext(t *testing.T) {
t.Parallel()
ctx, cancel := context.WithCancel(context.Background())
done := make(chan error, 1)
go func() {
done <- ListenAndServe(ctx, "127.0.0.1:0", http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
}))
}()
time.Sleep(20 * time.Millisecond)
cancel()
select {
case err := <-done:
if err != nil {
t.Fatal(err)
}
case <-time.After(2 * time.Second):
t.Fatal("server did not stop")
}
}
func newEmptyHTTPStore(t *testing.T) *sqlitestore.Store {
t.Helper()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(t.TempDir(), "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
return st
}
func newHTTPStore(t *testing.T) *sqlitestore.Store {
t.Helper()
st := newEmptyHTTPStore(t)
_, _ = st.CreateUser(context.Background(), store.CreateUserInput{DisplayName: "seed", Email: "seed@example.com"})
return st
}

View File

@ -0,0 +1,212 @@
package httpapi
import (
"errors"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/go-chi/chi/v5"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func (s *Server) search(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
results, err := s.store.SearchMessages(r.Context(), r.URL.Query().Get("workspace_id"), user.ID, r.URL.Query().Get("q"), queryInt(r, "limit", 50))
writeResult(w, map[string]any{"results": results}, err)
}
func (s *Server) createUpload(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
if s.uploadDir == "" {
writeError(w, http.StatusInternalServerError, errors.New("uploads are not configured"))
return
}
if err := r.ParseMultipartForm(32 << 20); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
workspaceID := r.FormValue("workspace_id")
file, header, err := r.FormFile("file")
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
defer file.Close()
if err := os.MkdirAll(s.uploadDir, 0o755); err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
tmp, err := os.CreateTemp(s.uploadDir, "upload-*")
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
defer tmp.Close()
size, err := io.Copy(tmp, file)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
contentType := header.Header.Get("Content-Type")
if contentType == "" {
contentType = "application/octet-stream"
}
upload, err := s.store.CreateUpload(r.Context(), store.CreateUploadInput{
WorkspaceID: workspaceID,
OwnerID: user.ID,
Filename: filepath.Base(header.Filename),
ContentType: contentType,
ByteSize: size,
StoragePath: tmp.Name(),
})
writeResultStatus(w, http.StatusCreated, map[string]any{"upload": upload}, err)
}
func (s *Server) getUpload(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
upload, err := s.store.GetUpload(r.Context(), chi.URLParam(r, "upload_id"), user.ID)
if err != nil {
writeError(w, http.StatusNotFound, err)
return
}
http.ServeFile(w, r, upload.StoragePath)
}
func (s *Server) attachUpload(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
UploadID string `json:"upload_id"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
err = s.store.AttachUpload(r.Context(), store.AttachUploadInput{MessageID: chi.URLParam(r, "message_id"), UploadID: body.UploadID, UserID: user.ID})
writeResult(w, map[string]any{"ok": true}, err)
}
func (s *Server) listDirectConversations(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
items, err := s.store.ListDirectConversations(r.Context(), r.URL.Query().Get("workspace_id"), user.ID)
writeResult(w, map[string]any{"conversations": items}, err)
}
func (s *Server) createDirectConversation(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
WorkspaceID string `json:"workspace_id"`
MemberIDs []string `json:"member_ids"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
dm, err := s.store.CreateDirectConversation(r.Context(), store.CreateDirectConversationInput{WorkspaceID: body.WorkspaceID, UserID: user.ID, MemberIDs: body.MemberIDs})
writeResultStatus(w, http.StatusCreated, map[string]any{"conversation": dm}, err)
}
func (s *Server) listDirectMessages(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
messages, err := s.store.ListDirectMessages(r.Context(), chi.URLParam(r, "conversation_id"), user.ID, queryInt64(r, "after_seq", 0), queryInt(r, "limit", 100))
writeResult(w, map[string]any{"messages": messages}, err)
}
func (s *Server) createDirectMessage(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
Body string `json:"body"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
message, event, err := s.store.CreateDirectMessage(r.Context(), store.CreateDirectMessageInput{ConversationID: chi.URLParam(r, "conversation_id"), AuthorID: user.ID, Body: body.Body})
if err == nil {
s.hub.Publish(event)
}
writeResultStatus(w, http.StatusCreated, map[string]any{"message": message, "event": event}, err)
}
func (s *Server) mattermostWebhook(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
Text string `json:"text"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: user.ID, Body: body.Text})
if err == nil {
s.hub.Publish(event)
}
writeResultStatus(w, http.StatusCreated, map[string]any{"message": message, "event": event}, err)
}
func (s *Server) slashCommand(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
if err := r.ParseForm(); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
text := strings.TrimSpace(r.FormValue("text"))
command := strings.TrimSpace(r.FormValue("command"))
if text == "" && command == "" {
writeError(w, http.StatusBadRequest, errors.New("slash command text is required"))
return
}
body := strings.TrimSpace(command + " " + text)
message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: user.ID, Body: body})
if err == nil {
s.hub.Publish(event)
}
writeResultStatus(w, http.StatusCreated, map[string]any{
"response_type": "in_channel",
"text": message.Body,
"message": message,
"event": event,
}, err)
}

View File

@ -0,0 +1,230 @@
package httpapi
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
type GitHubOAuthConfig struct {
ClientID string
ClientSecret string
PublicURL string
AuthURL string
TokenURL string
UserURL string
EmailsURL string
HTTPClient *http.Client
}
func (c GitHubOAuthConfig) withDefaults() GitHubOAuthConfig {
if c.AuthURL == "" {
c.AuthURL = "https://github.com/login/oauth/authorize"
}
if c.TokenURL == "" {
c.TokenURL = "https://github.com/login/oauth/access_token"
}
if c.UserURL == "" {
c.UserURL = "https://api.github.com/user"
}
if c.EmailsURL == "" {
c.EmailsURL = "https://api.github.com/user/emails"
}
if c.HTTPClient == nil {
c.HTTPClient = http.DefaultClient
}
return c
}
func (s *Server) githubStart(w http.ResponseWriter, r *http.Request) {
if s.githubOAuth.ClientID == "" || s.githubOAuth.ClientSecret == "" {
writeError(w, http.StatusNotImplemented, errors.New("github oauth is not configured"))
return
}
state, err := randomToken()
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
http.SetCookie(w, &http.Cookie{Name: "cc_github_state", Value: state, Path: "/", MaxAge: 600, HttpOnly: true, SameSite: http.SameSiteLaxMode})
values := url.Values{
"client_id": {s.githubOAuth.ClientID},
"redirect_uri": {s.githubRedirectURL(r)},
"scope": {"read:user user:email"},
"state": {state},
}
http.Redirect(w, r, s.githubOAuth.AuthURL+"?"+values.Encode(), http.StatusFound)
}
func (s *Server) githubCallback(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie("cc_github_state")
if err != nil || state.Value == "" || state.Value != r.URL.Query().Get("state") {
writeError(w, http.StatusBadRequest, errors.New("invalid github oauth state"))
return
}
code := strings.TrimSpace(r.URL.Query().Get("code"))
if code == "" {
writeError(w, http.StatusBadRequest, errors.New("github oauth code is required"))
return
}
token, err := s.exchangeGitHubCode(r.Context(), r, code)
if err != nil {
writeError(w, http.StatusBadGateway, err)
return
}
profile, err := s.fetchGitHubProfile(r.Context(), token)
if err != nil {
writeError(w, http.StatusBadGateway, err)
return
}
user, err := s.store.UpsertIdentityUser(r.Context(), store.UpsertIdentityUserInput{
Provider: "github",
ProviderSubject: strconv.FormatInt(profile.ID, 10),
Email: profile.Email,
DisplayName: firstNonEmpty(profile.Name, profile.Login, profile.Email),
AvatarURL: profile.AvatarURL,
})
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
session, err := s.store.CreateSession(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
setSessionCookie(w, session)
http.Redirect(w, r, "/", http.StatusFound)
}
func (s *Server) exchangeGitHubCode(ctx context.Context, r *http.Request, code string) (string, error) {
body := url.Values{
"client_id": {s.githubOAuth.ClientID},
"client_secret": {s.githubOAuth.ClientSecret},
"code": {code},
"redirect_uri": {s.githubRedirectURL(r)},
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.githubOAuth.TokenURL, strings.NewReader(body.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
resp, err := s.githubOAuth.HTTPClient.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return "", errors.New("github token exchange failed")
}
var out struct {
AccessToken string `json:"access_token"`
Error string `json:"error"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return "", err
}
if out.Error != "" {
return "", errors.New(out.Error)
}
if out.AccessToken == "" {
return "", errors.New("github access token missing")
}
return out.AccessToken, nil
}
type githubProfile struct {
ID int64 `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
func (s *Server) fetchGitHubProfile(ctx context.Context, token string) (githubProfile, error) {
var profile githubProfile
if err := s.githubGetJSON(ctx, s.githubOAuth.UserURL, token, &profile); err != nil {
return githubProfile{}, err
}
if profile.ID == 0 {
return githubProfile{}, errors.New("github profile id missing")
}
if profile.Email == "" {
var emails []struct {
Email string `json:"email"`
Primary bool `json:"primary"`
}
if err := s.githubGetJSON(ctx, s.githubOAuth.EmailsURL, token, &emails); err != nil {
return githubProfile{}, err
}
for _, item := range emails {
if item.Primary {
profile.Email = item.Email
break
}
}
}
return profile, nil
}
func (s *Server) githubGetJSON(ctx context.Context, endpoint, token string, out any) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
resp, err := s.githubOAuth.HTTPClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
return errors.New("github api request failed")
}
return json.NewDecoder(resp.Body).Decode(out)
}
func (s *Server) githubRedirectURL(r *http.Request) string {
base := strings.TrimRight(s.githubOAuth.PublicURL, "/")
if base == "" {
scheme := "http"
if r.TLS != nil {
scheme = "https"
}
base = scheme + "://" + r.Host
}
return base + "/api/auth/github/callback"
}
func setSessionCookie(w http.ResponseWriter, session store.Session) {
expires, _ := time.Parse(time.RFC3339Nano, session.ExpiresAt)
http.SetCookie(w, &http.Cookie{Name: "cc_session", Value: session.Token, Path: "/", Expires: expires, HttpOnly: true, SameSite: http.SameSiteLaxMode})
}
func randomToken() (string, error) {
var data [16]byte
if _, err := rand.Read(data[:]); err != nil {
return "", err
}
return hex.EncodeToString(data[:]), nil
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}

View File

@ -0,0 +1,200 @@
package httpapi
import (
"context"
"crypto/tls"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/openclaw/clickclack/apps/api/internal/realtime"
sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite"
)
func TestGitHubOAuthFlow(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/token":
if err := r.ParseForm(); err != nil {
t.Fatal(err)
}
switch r.FormValue("code") {
case "ok":
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "gh-token"})
case "empty":
_ = json.NewEncoder(w).Encode(map[string]string{})
case "api-fail":
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "api-fail"})
case "missing-id":
_ = json.NewEncoder(w).Encode(map[string]string{"access_token": "missing-id"})
default:
w.WriteHeader(http.StatusBadRequest)
}
case "/user":
switch r.Header.Get("Authorization") {
case "Bearer gh-token":
_ = json.NewEncoder(w).Encode(map[string]any{"id": 42, "login": "octo", "name": "Octo User", "avatar_url": "https://example.com/a.png"})
case "Bearer missing-id":
_ = json.NewEncoder(w).Encode(map[string]any{"login": "missing"})
default:
w.WriteHeader(http.StatusInternalServerError)
}
case "/emails":
_ = json.NewEncoder(w).Encode([]map[string]any{{"email": "octo@example.com", "primary": true}})
default:
w.WriteHeader(http.StatusNotFound)
}
}))
t.Cleanup(provider.Close)
server := httptest.NewServer(New(st, realtime.NewHub(), Options{GitHubOAuth: GitHubOAuthConfig{
ClientID: "client",
ClientSecret: "secret",
AuthURL: provider.URL + "/authorize",
TokenURL: provider.URL + "/token",
UserURL: provider.URL + "/user",
EmailsURL: provider.URL + "/emails",
}}).Handler())
t.Cleanup(server.Close)
client := &http.Client{CheckRedirect: func(_ *http.Request, _ []*http.Request) error { return http.ErrUseLastResponse }}
resp, err := client.Get(server.URL + "/api/auth/github/start")
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusFound || !strings.HasPrefix(resp.Header.Get("Location"), provider.URL+"/authorize?") {
t.Fatalf("unexpected start response: %s %s", resp.Status, resp.Header.Get("Location"))
}
var stateCookie *http.Cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == "cc_github_state" {
stateCookie = cookie
}
}
resp.Body.Close()
if stateCookie == nil || stateCookie.Value == "" {
t.Fatal("expected github state cookie")
}
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/auth/github/callback?code=ok&state="+stateCookie.Value, nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(stateCookie)
resp, err = client.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusFound || resp.Header.Get("Location") != "/" {
t.Fatalf("unexpected callback response: %s %s", resp.Status, resp.Header.Get("Location"))
}
var sessionCookie *http.Cookie
for _, cookie := range resp.Cookies() {
if cookie.Name == "cc_session" {
sessionCookie = cookie
}
}
resp.Body.Close()
if sessionCookie == nil || sessionCookie.Value == "" {
t.Fatal("expected session cookie")
}
req, err = http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(sessionCookie)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected session auth, got %s", resp.Status)
}
for _, tc := range []struct {
name string
code string
want int
}{
{"missing code", "", http.StatusBadRequest},
{"token status", "bad", http.StatusBadGateway},
{"missing token", "empty", http.StatusBadGateway},
{"api failure", "api-fail", http.StatusBadGateway},
{"missing profile id", "missing-id", http.StatusBadGateway},
} {
t.Run(tc.name, func(t *testing.T) {
path := server.URL + "/api/auth/github/callback?state=" + stateCookie.Value
if tc.code != "" {
path += "&code=" + tc.code
}
req, err := http.NewRequest(http.MethodGet, path, nil)
if err != nil {
t.Fatal(err)
}
req.AddCookie(stateCookie)
resp, err := client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != tc.want {
t.Fatalf("expected %d, got %s", tc.want, resp.Status)
}
})
}
if got := firstNonEmpty("", " ", "value"); got != "value" {
t.Fatalf("unexpected first non-empty value %q", got)
}
if got := firstNonEmpty("", " "); got != "" {
t.Fatalf("expected empty fallback, got %q", got)
}
req, err = http.NewRequest(http.MethodGet, server.URL+"/anything", nil)
if err != nil {
t.Fatal(err)
}
srv := New(st, realtime.NewHub(), Options{GitHubOAuth: GitHubOAuthConfig{PublicURL: "https://public.example"}})
if got := srv.githubRedirectURL(req); got != "https://public.example/api/auth/github/callback" {
t.Fatalf("unexpected public redirect url %q", got)
}
}
func TestGitHubOAuthErrors(t *testing.T) {
t.Parallel()
st := newEmptyHTTPStore(t)
server := httptest.NewServer(New(st, realtime.NewHub(), Options{}).Handler())
t.Cleanup(server.Close)
expectStatus(t, http.MethodGet, server.URL+"/api/auth/github/start", nil, http.StatusNotImplemented)
expectStatus(t, http.MethodGet, server.URL+"/api/auth/github/callback?code=x&state=bad", nil, http.StatusBadRequest)
srv := New(st, realtime.NewHub(), Options{GitHubOAuth: GitHubOAuthConfig{ClientID: "c", ClientSecret: "s", TokenURL: "://bad", UserURL: "://bad"}})
req := httptest.NewRequest(http.MethodGet, "https://example.test/callback", nil)
req.TLS = &tls.ConnectionState{}
if got := srv.githubRedirectURL(req); got != "https://example.test/api/auth/github/callback" {
t.Fatalf("unexpected tls redirect %q", got)
}
if _, err := srv.exchangeGitHubCode(context.Background(), req, "x"); err == nil {
t.Fatal("expected bad token url error")
}
if err := srv.githubGetJSON(context.Background(), "://bad", "token", &struct{}{}); err == nil {
t.Fatal("expected bad github api url error")
}
}

View File

@ -0,0 +1,105 @@
package httpapi
import (
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func (s *Server) updateChannel(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
Name string `json:"name"`
Kind string `json:"kind"`
Archived *bool `json:"archived"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
channel, event, err := s.store.UpdateChannel(r.Context(), store.UpdateChannelInput{ChannelID: chi.URLParam(r, "channel_id"), UserID: user.ID, Name: body.Name, Kind: body.Kind, Archived: body.Archived})
if err == nil {
s.hub.Publish(event)
}
writeResult(w, map[string]any{"channel": channel, "event": event}, err)
}
func (s *Server) updateMessage(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
Body string `json:"body"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
message, event, err := s.store.UpdateMessage(r.Context(), store.UpdateMessageInput{MessageID: chi.URLParam(r, "message_id"), UserID: user.ID, Body: body.Body})
if err == nil {
s.hub.Publish(event)
}
writeResult(w, map[string]any{"message": message, "event": event}, err)
}
func (s *Server) deleteMessage(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
message, event, err := s.store.DeleteMessage(r.Context(), store.DeleteMessageInput{MessageID: chi.URLParam(r, "message_id"), UserID: user.ID})
if err == nil {
s.hub.Publish(event)
}
writeResult(w, map[string]any{"message": message, "event": event}, err)
}
func (s *Server) publishEphemeral(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
WorkspaceID string `json:"workspace_id"`
ChannelID string `json:"channel_id"`
Type string `json:"type"`
Payload map[string]any `json:"payload"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
if body.Type != "typing.started" && body.Type != "typing.stopped" && body.Type != "presence.changed" {
writeError(w, http.StatusBadRequest, errors.New("unsupported ephemeral event type"))
return
}
if _, err := s.store.GetWorkspace(r.Context(), body.WorkspaceID, user.ID); err != nil {
writeError(w, http.StatusForbidden, err)
return
}
if body.Payload == nil {
body.Payload = map[string]any{}
}
body.Payload["user_id"] = user.ID
event := store.Event{
ID: "eph_" + time.Now().UTC().Format("20060102150405.000000000"),
Type: body.Type,
WorkspaceID: body.WorkspaceID,
ChannelID: body.ChannelID,
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
Payload: body.Payload,
}
s.hub.Publish(event)
writeJSON(w, http.StatusAccepted, map[string]any{"event": event})
}

View File

@ -0,0 +1,128 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"path/filepath"
"testing"
"github.com/openclaw/clickclack/apps/api/internal/realtime"
"github.com/openclaw/clickclack/apps/api/internal/store"
sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite"
)
func TestMutationAndEphemeralEndpoints(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)
updatedChannel := patchJSON[struct {
Channel store.Channel `json:"channel"`
Event store.Event `json:"event"`
}](t, server.URL+"/api/channels/"+channels[0].ID, map[string]any{"name": "dock"})
if updatedChannel.Channel.Name != "dock" || updatedChannel.Event.Type != "channel.updated" {
t.Fatalf("unexpected channel update: %#v", updatedChannel)
}
message := postJSON[struct {
Message store.Message `json:"message"`
}](t, server.URL+"/api/channels/"+channels[0].ID+"/messages", map[string]string{"body": "original"}).Message
updatedMessage := patchJSON[struct {
Message store.Message `json:"message"`
Event store.Event `json:"event"`
}](t, server.URL+"/api/messages/"+message.ID, map[string]string{"body": "edited"})
if updatedMessage.Message.Body != "edited" || updatedMessage.Event.Type != "message.updated" {
t.Fatalf("unexpected message update: %#v", updatedMessage)
}
deletedMessage := deleteJSONBody[struct {
Message store.Message `json:"message"`
Event store.Event `json:"event"`
}](t, server.URL+"/api/messages/"+message.ID)
if deletedMessage.Message.DeletedAt == nil || deletedMessage.Event.Type != "message.deleted" {
t.Fatalf("unexpected message delete: %#v", deletedMessage)
}
ephemeral := postJSON[struct {
Event store.Event `json:"event"`
}](t, server.URL+"/api/realtime/ephemeral", map[string]any{"workspace_id": workspaces[0].ID, "channel_id": channels[0].ID, "type": "typing.started"})
if ephemeral.Event.Type != "typing.started" || ephemeral.Event.Cursor != "" {
t.Fatalf("unexpected ephemeral event: %#v", ephemeral.Event)
}
presence := postJSON[struct {
Event store.Event `json:"event"`
}](t, server.URL+"/api/realtime/ephemeral", map[string]any{"workspace_id": workspaces[0].ID, "type": "presence.changed", "payload": map[string]any{"status": "afk"}})
if presence.Event.Type != "presence.changed" {
t.Fatalf("unexpected presence event: %#v", presence.Event)
}
expectStatus(t, http.MethodPatch, server.URL+"/api/channels/"+channels[0].ID, bytes.NewReader([]byte(`{`)), http.StatusBadRequest)
expectStatus(t, http.MethodPatch, server.URL+"/api/channels/missing", bytes.NewReader([]byte(`{"name":"missing"}`)), http.StatusBadRequest)
expectStatus(t, http.MethodPatch, server.URL+"/api/messages/"+message.ID, bytes.NewReader([]byte(`{`)), http.StatusBadRequest)
expectStatus(t, http.MethodPatch, server.URL+"/api/messages/"+message.ID, bytes.NewReader([]byte(`{"body":" "}`)), http.StatusBadRequest)
expectStatus(t, http.MethodDelete, server.URL+"/api/messages/missing", nil, http.StatusBadRequest)
expectStatus(t, http.MethodPost, server.URL+"/api/realtime/ephemeral", bytes.NewReader([]byte(`{`)), http.StatusBadRequest)
expectStatus(t, http.MethodPost, server.URL+"/api/realtime/ephemeral", bytes.NewReader([]byte(`{"workspace_id":"`+workspaces[0].ID+`","type":"bad"}`)), http.StatusBadRequest)
expectStatus(t, http.MethodPost, server.URL+"/api/realtime/ephemeral", bytes.NewReader([]byte(`{"workspace_id":"missing","type":"typing.started"}`)), http.StatusForbidden)
}
func patchJSON[T any](t *testing.T, endpoint string, body any) T {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewReader(payload))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", "application/json")
return doJSON[T](t, req)
}
func deleteJSONBody[T any](t *testing.T, endpoint string) T {
t.Helper()
req, err := http.NewRequest(http.MethodDelete, endpoint, nil)
if err != nil {
t.Fatal(err)
}
return doJSON[T](t, req)
}
func doJSON[T any](t *testing.T, req *http.Request) T {
t.Helper()
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
t.Fatalf("%s %s: %s", req.Method, req.URL, resp.Status)
}
var out T
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out
}

View File

@ -0,0 +1,412 @@
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"io/fs"
"net/http"
"strconv"
"strings"
"time"
"github.com/coder/websocket"
"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
"github.com/openclaw/clickclack/apps/api/internal/realtime"
"github.com/openclaw/clickclack/apps/api/internal/store"
"github.com/openclaw/clickclack/apps/api/internal/webassets"
)
type Server struct {
store store.Store
hub *realtime.Hub
uploadDir string
githubOAuth GitHubOAuthConfig
}
type Options struct {
UploadDir string
GitHubOAuth GitHubOAuthConfig
}
func New(st store.Store, hub *realtime.Hub, options Options) *Server {
return &Server{store: st, hub: hub, uploadDir: options.UploadDir, githubOAuth: options.GitHubOAuth.withDefaults()}
}
func (s *Server) Handler() http.Handler {
r := chi.NewRouter()
r.Use(middleware.RequestID)
r.Use(middleware.RealIP)
r.Use(middleware.Logger)
r.Use(middleware.Recoverer)
r.Route("/api", func(r chi.Router) {
r.Post("/auth/magic/request", s.requestMagicLink)
r.Post("/auth/magic/consume", s.consumeMagicLink)
r.Get("/auth/github/start", s.githubStart)
r.Get("/auth/github/callback", s.githubCallback)
r.Get("/me", s.me)
r.Get("/workspaces", s.listWorkspaces)
r.Post("/workspaces", s.createWorkspace)
r.Get("/workspaces/{workspace_id}", s.getWorkspace)
r.Get("/workspaces/{workspace_id}/channels", s.listChannels)
r.Post("/workspaces/{workspace_id}/channels", s.createChannel)
r.Patch("/channels/{channel_id}", s.updateChannel)
r.Get("/channels/{channel_id}/messages", s.listMessages)
r.Post("/channels/{channel_id}/messages", s.createMessage)
r.Patch("/messages/{message_id}", s.updateMessage)
r.Delete("/messages/{message_id}", s.deleteMessage)
r.Get("/messages/{message_id}/thread", s.getThread)
r.Post("/messages/{message_id}/thread/replies", s.createThreadReply)
r.Post("/messages/{message_id}/reactions", s.addReaction)
r.Delete("/messages/{message_id}/reactions/{emoji}", s.removeReaction)
r.Get("/realtime/events", s.listEvents)
r.Post("/realtime/ephemeral", s.publishEphemeral)
r.Get("/realtime/ws", s.websocket)
r.Get("/search", s.search)
r.Post("/uploads", s.createUpload)
r.Get("/uploads/{upload_id}", s.getUpload)
r.Post("/messages/{message_id}/attachments", s.attachUpload)
r.Get("/dms", s.listDirectConversations)
r.Post("/dms", s.createDirectConversation)
r.Get("/dms/{conversation_id}/messages", s.listDirectMessages)
r.Post("/dms/{conversation_id}/messages", s.createDirectMessage)
r.Post("/hooks/mattermost/{channel_id}", s.mattermostWebhook)
r.Post("/hooks/slash/{channel_id}", s.slashCommand)
})
r.NotFound(s.serveSPA)
r.Get("/*", s.serveSPA)
return r
}
func (s *Server) me(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"user": user})
}
func (s *Server) listWorkspaces(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
items, err := s.store.ListWorkspaces(r.Context(), user.ID)
writeResult(w, map[string]any{"workspaces": items}, err)
}
func (s *Server) createWorkspace(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
Name string `json:"name"`
Slug string `json:"slug"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
workspace, err := s.store.CreateWorkspace(r.Context(), store.CreateWorkspaceInput{Name: body.Name, Slug: body.Slug}, user.ID)
writeResultStatus(w, http.StatusCreated, map[string]any{"workspace": workspace}, err)
}
func (s *Server) getWorkspace(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
workspace, err := s.store.GetWorkspace(r.Context(), chi.URLParam(r, "workspace_id"), user.ID)
writeResult(w, map[string]any{"workspace": workspace}, err)
}
func (s *Server) listChannels(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
channels, err := s.store.ListChannels(r.Context(), chi.URLParam(r, "workspace_id"), user.ID)
writeResult(w, map[string]any{"channels": channels}, err)
}
func (s *Server) createChannel(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
Name string `json:"name"`
Kind string `json:"kind"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
channel, event, err := s.store.CreateChannel(r.Context(), store.CreateChannelInput{WorkspaceID: chi.URLParam(r, "workspace_id"), Name: body.Name, Kind: body.Kind, UserID: user.ID})
if err == nil {
s.hub.Publish(event)
}
writeResultStatus(w, http.StatusCreated, map[string]any{"channel": channel, "event": event}, err)
}
func (s *Server) listMessages(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
messages, err := s.store.ListMessages(r.Context(), chi.URLParam(r, "channel_id"), user.ID, queryInt64(r, "after_seq", 0), queryInt(r, "limit", 100))
writeResult(w, map[string]any{"messages": messages}, err)
}
func (s *Server) createMessage(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
Body string `json:"body"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
message, event, err := s.store.CreateMessage(r.Context(), store.CreateMessageInput{ChannelID: chi.URLParam(r, "channel_id"), AuthorID: user.ID, Body: body.Body})
if err == nil {
s.hub.Publish(event)
}
writeResultStatus(w, http.StatusCreated, map[string]any{"message": message, "event": event}, err)
}
func (s *Server) getThread(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
root, replies, state, err := s.store.GetThread(r.Context(), chi.URLParam(r, "message_id"), user.ID, queryInt(r, "limit", 100))
writeResult(w, map[string]any{"root": root, "replies": replies, "thread_state": state}, err)
}
func (s *Server) createThreadReply(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
Body string `json:"body"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
message, state, events, err := s.store.CreateThreadReply(r.Context(), store.CreateThreadReplyInput{RootMessageID: chi.URLParam(r, "message_id"), AuthorID: user.ID, Body: body.Body})
if err == nil {
s.hub.PublishMany(events)
}
writeResultStatus(w, http.StatusCreated, map[string]any{"message": message, "thread_state": state, "events": events}, err)
}
func (s *Server) addReaction(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
var body struct {
Emoji string `json:"emoji"`
}
if err := readJSON(r, &body); err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
event, err := s.store.AddReaction(r.Context(), store.CreateReactionInput{MessageID: chi.URLParam(r, "message_id"), UserID: user.ID, Emoji: body.Emoji})
if err == nil {
s.hub.Publish(event)
}
writeResultStatus(w, http.StatusCreated, map[string]any{"event": event}, err)
}
func (s *Server) removeReaction(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
event, err := s.store.RemoveReaction(r.Context(), store.CreateReactionInput{MessageID: chi.URLParam(r, "message_id"), UserID: user.ID, Emoji: chi.URLParam(r, "emoji")})
if err == nil {
s.hub.Publish(event)
}
writeResult(w, map[string]any{"event": event}, err)
}
func (s *Server) listEvents(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
events, err := s.store.ListEventsAfter(r.Context(), r.URL.Query().Get("workspace_id"), user.ID, r.URL.Query().Get("after_cursor"), queryInt(r, "limit", 200))
writeResult(w, map[string]any{"events": events}, err)
}
func (s *Server) websocket(w http.ResponseWriter, r *http.Request) {
user, err := s.currentUser(r)
if err != nil {
writeError(w, http.StatusUnauthorized, err)
return
}
workspaceID := r.URL.Query().Get("workspace_id")
if workspaceID == "" {
writeError(w, http.StatusBadRequest, errors.New("workspace_id is required"))
return
}
if _, err := s.store.GetWorkspace(r.Context(), workspaceID, user.ID); err != nil {
writeError(w, http.StatusForbidden, err)
return
}
conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{InsecureSkipVerify: true})
if err != nil {
return
}
defer conn.CloseNow()
ctx := r.Context()
backlog, err := s.store.ListEventsAfter(ctx, workspaceID, user.ID, r.URL.Query().Get("after_cursor"), 500)
if err != nil {
_ = conn.Close(websocket.StatusPolicyViolation, err.Error())
return
}
for _, event := range backlog {
if err := writeWS(ctx, conn, event); err != nil {
return
}
}
events, unsubscribe := s.hub.Subscribe(workspaceID)
defer unsubscribe()
for {
select {
case <-ctx.Done():
return
case event := <-events:
if err := writeWS(ctx, conn, event); err != nil {
return
}
}
}
}
func (s *Server) currentUser(r *http.Request) (store.User, error) {
if auth := r.Header.Get("Authorization"); strings.HasPrefix(auth, "Bearer ") {
return s.store.GetSessionUser(r.Context(), strings.TrimSpace(strings.TrimPrefix(auth, "Bearer ")))
}
if cookie, err := r.Cookie("cc_session"); err == nil && cookie.Value != "" {
return s.store.GetSessionUser(r.Context(), cookie.Value)
}
if id := r.Header.Get("X-ClickClack-User"); id != "" {
return s.store.GetUser(r.Context(), id)
}
return s.store.FirstUser(r.Context())
}
func (s *Server) serveSPA(w http.ResponseWriter, r *http.Request) {
dist, err := fs.Sub(webassets.Dist, "dist")
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
if r.URL.Path != "/" {
if file, err := dist.Open(strings.TrimPrefix(r.URL.Path, "/")); err == nil {
_ = file.Close()
http.FileServer(http.FS(dist)).ServeHTTP(w, r)
return
}
}
index, err := fs.ReadFile(dist, "index.html")
if err != nil {
writeError(w, http.StatusInternalServerError, err)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write(index)
}
func writeWS(ctx context.Context, conn *websocket.Conn, event store.Event) error {
ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
body, err := json.Marshal(event)
if err != nil {
return err
}
return conn.Write(ctx, websocket.MessageText, body)
}
func readJSON(r *http.Request, out any) error {
defer r.Body.Close()
return json.NewDecoder(r.Body).Decode(out)
}
func writeResult(w http.ResponseWriter, body any, err error) {
writeResultStatus(w, http.StatusOK, body, err)
}
func writeResultStatus(w http.ResponseWriter, status int, body any, err error) {
if err != nil {
writeError(w, http.StatusBadRequest, err)
return
}
writeJSON(w, status, body)
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeError(w http.ResponseWriter, status int, err error) {
writeJSON(w, status, map[string]any{"error": err.Error()})
}
func queryInt(r *http.Request, key string, fallback int) int {
value, err := strconv.Atoi(r.URL.Query().Get(key))
if err != nil {
return fallback
}
return value
}
func queryInt64(r *http.Request, key string, fallback int64) int64 {
value, err := strconv.ParseInt(r.URL.Query().Get(key), 10, 64)
if err != nil {
return fallback
}
return value
}
func ListenAndServe(ctx context.Context, addr string, handler http.Handler) error {
server := &http.Server{Addr: addr, Handler: handler, ReadHeaderTimeout: 5 * time.Second}
go func() {
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
err := server.ListenAndServe()
if errors.Is(err, http.ErrServerClosed) {
return nil
}
return fmt.Errorf("serve %s: %w", addr, err)
}

View File

@ -0,0 +1,441 @@
package httpapi
import (
"bytes"
"context"
"encoding/json"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"net/url"
"path/filepath"
"strings"
"testing"
"time"
"github.com/coder/websocket"
"github.com/openclaw/clickclack/apps/api/internal/realtime"
"github.com/openclaw/clickclack/apps/api/internal/store"
sqlitestore "github.com/openclaw/clickclack/apps/api/internal/store/sqlite"
)
func TestChatAPIVerticalSlice(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
second, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Second", Email: "second@example.com"})
if err != nil {
t.Fatal(err)
}
hub := realtime.NewHub()
server := httptest.NewServer(New(st, hub, Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)
me := getJSON[struct {
User store.User `json:"user"`
}](t, server.URL+"/api/me")
if me.User.ID != owner.ID {
t.Fatalf("expected owner %s, got %s", owner.ID, me.User.ID)
}
workspaces := getJSON[struct {
Workspaces []store.Workspace `json:"workspaces"`
}](t, server.URL+"/api/workspaces")
workspace := workspaces.Workspaces[0]
createdWorkspace := postJSON[struct {
Workspace store.Workspace `json:"workspace"`
}](t, server.URL+"/api/workspaces", map[string]string{"name": "Side Dock"})
if createdWorkspace.Workspace.Slug != "side-dock" {
t.Fatalf("unexpected workspace slug %q", createdWorkspace.Workspace.Slug)
}
gotWorkspace := getJSON[struct {
Workspace store.Workspace `json:"workspace"`
}](t, server.URL+"/api/workspaces/"+workspace.ID)
if gotWorkspace.Workspace.ID != workspace.ID {
t.Fatalf("unexpected workspace response: %#v", gotWorkspace.Workspace)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, second.ID, "member"); err != nil {
t.Fatal(err)
}
channels := getJSON[struct {
Channels []store.Channel `json:"channels"`
}](t, server.URL+"/api/workspaces/"+workspace.ID+"/channels")
channel := channels.Channels[0]
createdChannel := postJSON[struct {
Channel store.Channel `json:"channel"`
}](t, server.URL+"/api/workspaces/"+workspace.ID+"/channels", map[string]string{"name": "random"})
if createdChannel.Channel.Name != "random" {
t.Fatalf("unexpected channel: %#v", createdChannel.Channel)
}
wsURL := strings.Replace(server.URL, "http://", "ws://", 1) + "/api/realtime/ws?workspace_id=" + url.QueryEscape(workspace.ID)
conn, _, err := websocket.Dial(ctx, wsURL, nil)
if err != nil {
t.Fatal(err)
}
defer conn.CloseNow()
created := postJSON[struct {
Message store.Message `json:"message"`
Event store.Event `json:"event"`
}](t, server.URL+"/api/channels/"+channel.ID+"/messages", map[string]string{"body": "findable **lobster**"})
if created.Message.ChannelSeq == nil || *created.Message.ChannelSeq != 1 {
t.Fatalf("unexpected channel seq: %#v", created.Message.ChannelSeq)
}
if event := readEventType(t, conn, "message.created"); event.Type != "message.created" {
t.Fatalf("unexpected websocket event %s", event.Type)
}
messages := getJSON[struct {
Messages []store.Message `json:"messages"`
}](t, server.URL+"/api/channels/"+channel.ID+"/messages")
if len(messages.Messages) != 1 {
t.Fatalf("expected one root message, got %d", len(messages.Messages))
}
reply := postJSON[struct {
Message store.Message `json:"message"`
ThreadState store.ThreadState `json:"thread_state"`
}](t, server.URL+"/api/messages/"+created.Message.ID+"/thread/replies", map[string]string{"body": "thread _reply_"})
if reply.ThreadState.ReplyCount != 1 {
t.Fatalf("expected reply count 1, got %d", reply.ThreadState.ReplyCount)
}
thread := getJSON[struct {
Root store.Message `json:"root"`
Replies []store.Message `json:"replies"`
ThreadState store.ThreadState `json:"thread_state"`
}](t, server.URL+"/api/messages/"+created.Message.ID+"/thread")
if thread.Root.ID != created.Message.ID || len(thread.Replies) != 1 {
t.Fatalf("unexpected thread payload: %#v", thread)
}
search := getJSON[struct {
Results []store.SearchResult `json:"results"`
}](t, server.URL+"/api/search?workspace_id="+url.QueryEscape(workspace.ID)+"&q=lobster")
if len(search.Results) != 1 || search.Results[0].Message.ID != created.Message.ID {
t.Fatalf("unexpected search results: %#v", search.Results)
}
upload := uploadFile(t, server.URL+"/api/uploads", workspace.ID, "note.txt", "hello upload")
attach := postJSON[map[string]bool](t, server.URL+"/api/messages/"+created.Message.ID+"/attachments", map[string]string{"upload_id": upload.ID})
if !attach["ok"] {
t.Fatal("expected attachment success")
}
body := getBody(t, server.URL+"/api/uploads/"+upload.ID)
if body != "hello upload" {
t.Fatalf("unexpected upload body %q", body)
}
reaction := postJSON[struct {
Event store.Event `json:"event"`
}](t, server.URL+"/api/messages/"+created.Message.ID+"/reactions", map[string]string{"emoji": "lobster"})
if reaction.Event.Type != "reaction.added" {
t.Fatalf("unexpected reaction event: %s", reaction.Event.Type)
}
deleteJSON(t, server.URL+"/api/messages/"+created.Message.ID+"/reactions/lobster")
dm := postJSON[struct {
Conversation store.DirectConversation `json:"conversation"`
}](t, server.URL+"/api/dms", map[string]any{"workspace_id": workspace.ID, "member_ids": []string{second.ID}})
if len(dm.Conversation.Members) != 2 {
t.Fatalf("expected two dm members, got %d", len(dm.Conversation.Members))
}
dmMessage := postJSON[struct {
Message store.Message `json:"message"`
}](t, server.URL+"/api/dms/"+dm.Conversation.ID+"/messages", map[string]string{"body": "private click"})
if dmMessage.Message.DirectConversationID != dm.Conversation.ID {
t.Fatalf("unexpected dm message: %#v", dmMessage.Message)
}
dms := getJSON[struct {
Conversations []store.DirectConversation `json:"conversations"`
}](t, server.URL+"/api/dms?workspace_id="+url.QueryEscape(workspace.ID))
if len(dms.Conversations) != 1 {
t.Fatalf("expected one dm conversation, got %d", len(dms.Conversations))
}
dmMessages := getJSON[struct {
Messages []store.Message `json:"messages"`
}](t, server.URL+"/api/dms/"+dm.Conversation.ID+"/messages")
if len(dmMessages.Messages) != 1 {
t.Fatalf("expected one dm message, got %d", len(dmMessages.Messages))
}
webhook := postJSON[struct {
Message store.Message `json:"message"`
}](t, server.URL+"/api/hooks/mattermost/"+channel.ID, map[string]string{"text": "from webhook"})
if webhook.Message.Body != "from webhook" {
t.Fatalf("unexpected webhook body %q", webhook.Message.Body)
}
slash := postForm[struct {
Text string `json:"text"`
Message store.Message `json:"message"`
}](t, server.URL+"/api/hooks/slash/"+channel.ID, url.Values{"command": {"/clack"}, "text": {"from slash"}})
if slash.Text != "/clack from slash" || slash.Message.Body != slash.Text {
t.Fatalf("unexpected slash response: %#v", slash)
}
events := getJSON[struct {
Events []store.Event `json:"events"`
}](t, server.URL+"/api/realtime/events?workspace_id="+url.QueryEscape(workspace.ID)+"&after_cursor="+url.QueryEscape(created.Event.Cursor))
if len(events.Events) == 0 {
t.Fatal("expected recoverable events after cursor")
}
}
func TestHTTPErrorPathsAndSPA(t *testing.T) {
t.Parallel()
ctx := context.Background()
dataDir := t.TempDir()
st, err := sqlitestore.Open("sqlite://" + filepath.Join(dataDir, "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(New(st, realtime.NewHub(), Options{UploadDir: filepath.Join(dataDir, "uploads")}).Handler())
t.Cleanup(server.Close)
index := getBody(t, server.URL+"/")
if !strings.Contains(index, "ClickClack") {
t.Fatalf("expected embedded app shell, got %q", index)
}
fallback := getBody(t, server.URL+"/not-a-real-route")
if !strings.Contains(fallback, "ClickClack") {
t.Fatal("expected SPA fallback")
}
req, err := http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("X-ClickClack-User", owner.ID)
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected header auth success, got %s", resp.Status)
}
resp.Body.Close()
link := postJSON[struct {
Token string `json:"token"`
}](t, server.URL+"/api/auth/magic/request", map[string]string{"email": "auth@example.com", "display_name": "Auth User"})
auth := postJSON[struct {
User store.User `json:"user"`
Session store.Session `json:"session"`
}](t, server.URL+"/api/auth/magic/consume", map[string]string{"token": link.Token})
if auth.User.DisplayName != "Auth User" || auth.Session.Token == "" {
t.Fatalf("unexpected auth payload: %#v", auth)
}
req, err = http.NewRequest(http.MethodGet, server.URL+"/api/me", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Authorization", "Bearer "+auth.Session.Token)
resp, err = http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("expected bearer auth success, got %s %s", resp.Status, string(body))
}
expectStatus(t, http.MethodPost, server.URL+"/api/workspaces", strings.NewReader("{"), http.StatusBadRequest)
expectStatus(t, http.MethodPost, server.URL+"/api/auth/magic/request", strings.NewReader(`{"email":""}`), http.StatusBadRequest)
expectStatus(t, http.MethodPost, server.URL+"/api/auth/magic/consume", strings.NewReader(`{"token":"missing"}`), http.StatusBadRequest)
expectStatus(t, http.MethodPost, server.URL+"/api/workspaces/missing/channels", strings.NewReader(`{"name":"x"}`), http.StatusBadRequest)
expectStatus(t, http.MethodGet, server.URL+"/api/realtime/ws", nil, http.StatusBadRequest)
expectStatus(t, http.MethodPost, server.URL+"/api/uploads", strings.NewReader("not multipart"), http.StatusBadRequest)
expectStatus(t, http.MethodGet, server.URL+"/api/uploads/missing", nil, http.StatusNotFound)
expectStatus(t, http.MethodGet, server.URL+"/api/search?workspace_id=missing&q=x", nil, http.StatusBadRequest)
}
func getJSON[T any](t *testing.T, endpoint string) T {
t.Helper()
resp, err := http.Get(endpoint)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("GET %s: %s %s", endpoint, resp.Status, string(body))
}
var out T
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out
}
func postJSON[T any](t *testing.T, endpoint string, body any) T {
t.Helper()
payload, err := json.Marshal(body)
if err != nil {
t.Fatal(err)
}
resp, err := http.Post(endpoint, "application/json", bytes.NewReader(payload))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("POST %s: %s %s", endpoint, resp.Status, string(body))
}
var out T
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out
}
func postForm[T any](t *testing.T, endpoint string, form url.Values) T {
t.Helper()
resp, err := http.PostForm(endpoint, form)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("POST %s: %s %s", endpoint, resp.Status, string(body))
}
var out T
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out
}
func deleteJSON(t *testing.T, endpoint string) {
t.Helper()
req, err := http.NewRequest(http.MethodDelete, endpoint, nil)
if err != nil {
t.Fatal(err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("DELETE %s: %s %s", endpoint, resp.Status, string(body))
}
}
func expectStatus(t *testing.T, method, endpoint string, body io.Reader, status int) {
t.Helper()
req, err := http.NewRequest(method, endpoint, body)
if err != nil {
t.Fatal(err)
}
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != status {
payload, _ := io.ReadAll(resp.Body)
t.Fatalf("%s %s: expected %d, got %s %s", method, endpoint, status, resp.Status, string(payload))
}
}
func uploadFile(t *testing.T, endpoint, workspaceID, filename, content string) store.Upload {
t.Helper()
var body bytes.Buffer
writer := multipart.NewWriter(&body)
if err := writer.WriteField("workspace_id", workspaceID); err != nil {
t.Fatal(err)
}
part, err := writer.CreateFormFile("file", filename)
if err != nil {
t.Fatal(err)
}
if _, err := part.Write([]byte(content)); err != nil {
t.Fatal(err)
}
if err := writer.Close(); err != nil {
t.Fatal(err)
}
resp, err := http.Post(endpoint, writer.FormDataContentType(), &body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
t.Fatalf("upload: %s %s", resp.Status, string(body))
}
var out struct {
Upload store.Upload `json:"upload"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
return out.Upload
}
func getBody(t *testing.T, endpoint string) string {
t.Helper()
resp, err := http.Get(endpoint)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatal(err)
}
if resp.StatusCode >= 300 {
t.Fatalf("GET %s: %s %s", endpoint, resp.Status, string(body))
}
return string(body)
}
func readEventType(t *testing.T, conn *websocket.Conn, eventType string) store.Event {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for {
_, body, err := conn.Read(ctx)
if err != nil {
t.Fatal(err)
}
var event store.Event
if err := json.Unmarshal(body, &event); err != nil {
t.Fatal(err)
}
if event.Type == eventType {
return event
}
}
}

View File

@ -0,0 +1,49 @@
package realtime
import (
"sync"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
type Hub struct {
mu sync.RWMutex
subs map[string]map[chan store.Event]struct{}
}
func NewHub() *Hub {
return &Hub{subs: map[string]map[chan store.Event]struct{}{}}
}
func (h *Hub) Subscribe(workspaceID string) (<-chan store.Event, func()) {
ch := make(chan store.Event, 32)
h.mu.Lock()
if h.subs[workspaceID] == nil {
h.subs[workspaceID] = map[chan store.Event]struct{}{}
}
h.subs[workspaceID][ch] = struct{}{}
h.mu.Unlock()
return ch, func() {
h.mu.Lock()
delete(h.subs[workspaceID], ch)
close(ch)
h.mu.Unlock()
}
}
func (h *Hub) Publish(event store.Event) {
h.mu.RLock()
defer h.mu.RUnlock()
for ch := range h.subs[event.WorkspaceID] {
select {
case ch <- event:
default:
}
}
}
func (h *Hub) PublishMany(events []store.Event) {
for _, event := range events {
h.Publish(event)
}
}

View File

@ -0,0 +1,38 @@
package realtime
import (
"testing"
"time"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func TestHubSubscribePublishAndUnsubscribe(t *testing.T) {
t.Parallel()
hub := NewHub()
events, unsubscribe := hub.Subscribe("wsp_1")
event := store.Event{ID: "evt_1", WorkspaceID: "wsp_1", Type: "message.created"}
hub.Publish(event)
select {
case got := <-events:
if got.ID != event.ID {
t.Fatalf("expected %s, got %s", event.ID, got.ID)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for event")
}
hub.PublishMany([]store.Event{{ID: "evt_2", WorkspaceID: "wsp_other"}})
select {
case got := <-events:
t.Fatalf("unexpected event for other workspace: %#v", got)
case <-time.After(10 * time.Millisecond):
}
unsubscribe()
_, ok := <-events
if ok {
t.Fatal("expected subscription channel to close")
}
}

View File

@ -0,0 +1,135 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"strings"
"time"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func (s *Store) CreateMagicLink(ctx context.Context, email, displayName string) (store.MagicLink, error) {
email = strings.ToLower(strings.TrimSpace(email))
if email == "" {
return store.MagicLink{}, errors.New("email is required")
}
link := store.MagicLink{
ID: newID("mln"),
Token: newID("mgt"),
Email: email,
DisplayName: strings.TrimSpace(displayName),
CreatedAt: now(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute).Format(time.RFC3339Nano),
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO auth_magic_links (id, token, email, display_name, created_at, expires_at)
VALUES (?, ?, ?, ?, ?, ?)`, link.ID, link.Token, link.Email, link.DisplayName, link.CreatedAt, link.ExpiresAt)
return link, err
}
func (s *Store) ConsumeMagicLink(ctx context.Context, token string) (store.User, store.Session, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.User{}, store.Session{}, err
}
defer tx.Rollback()
link, err := scanMagicLink(tx.QueryRowContext(ctx, `
SELECT id, token, email, display_name, created_at, expires_at, used_at
FROM auth_magic_links WHERE token = ?`, strings.TrimSpace(token)))
if err != nil {
return store.User{}, store.Session{}, err
}
if link.UsedAt != nil {
return store.User{}, store.Session{}, errors.New("magic link already used")
}
expiresAt, err := time.Parse(time.RFC3339Nano, link.ExpiresAt)
if err != nil || time.Now().UTC().After(expiresAt) {
return store.User{}, store.Session{}, errors.New("magic link expired")
}
user, err := getOrCreateMagicUser(ctx, tx, link.Email, link.DisplayName)
if err != nil {
return store.User{}, store.Session{}, err
}
usedAt := now()
if _, err := tx.ExecContext(ctx, `UPDATE auth_magic_links SET used_at = ? WHERE id = ?`, usedAt, link.ID); err != nil {
return store.User{}, store.Session{}, err
}
session, err := createSessionTx(ctx, tx, user.ID)
if err != nil {
return store.User{}, store.Session{}, err
}
return user, session, tx.Commit()
}
func (s *Store) GetSessionUser(ctx context.Context, token string) (store.User, error) {
return scanUser(s.db.QueryRowContext(ctx, `
SELECT u.id, u.display_name, u.avatar_url, u.created_at
FROM sessions s
JOIN users u ON u.id = s.user_id
WHERE s.token = ? AND s.revoked_at IS NULL AND s.expires_at > ?`, token, now()))
}
func (s *Store) CreateSession(ctx context.Context, userID string) (store.Session, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Session{}, err
}
defer tx.Rollback()
session, err := createSessionTx(ctx, tx, userID)
if err != nil {
return store.Session{}, err
}
return session, tx.Commit()
}
func getOrCreateMagicUser(ctx context.Context, tx *sql.Tx, email, displayName string) (store.User, error) {
user, err := scanUser(tx.QueryRowContext(ctx, `
SELECT u.id, u.display_name, u.avatar_url, u.created_at
FROM identities i
JOIN users u ON u.id = i.user_id
WHERE i.email = ?
ORDER BY u.created_at LIMIT 1`, email))
if err == nil {
return user, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return store.User{}, err
}
user = store.User{ID: newID("usr"), DisplayName: strings.TrimSpace(displayName), CreatedAt: now()}
if user.DisplayName == "" {
user.DisplayName = email
}
if _, err := tx.ExecContext(ctx, `INSERT INTO users (id, display_name, avatar_url, created_at) VALUES (?, ?, '', ?)`, user.ID, user.DisplayName, user.CreatedAt); err != nil {
return store.User{}, err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO identities (id, user_id, provider, provider_subject, email, created_at)
VALUES (?, ?, 'magic', ?, ?, ?)`, newID("idn"), user.ID, email, email, user.CreatedAt)
return user, err
}
func createSessionTx(ctx context.Context, tx *sql.Tx, userID string) (store.Session, error) {
session := store.Session{
ID: newID("ses"),
Token: newID("sst"),
UserID: userID,
CreatedAt: now(),
ExpiresAt: time.Now().UTC().Add(30 * 24 * time.Hour).Format(time.RFC3339Nano),
}
_, err := tx.ExecContext(ctx, `
INSERT INTO sessions (id, token, user_id, created_at, expires_at)
VALUES (?, ?, ?, ?, ?)`, session.ID, session.Token, session.UserID, session.CreatedAt, session.ExpiresAt)
return session, err
}
func scanMagicLink(row scanner) (store.MagicLink, error) {
var link store.MagicLink
var usedAt sql.NullString
err := row.Scan(&link.ID, &link.Token, &link.Email, &link.DisplayName, &link.CreatedAt, &link.ExpiresAt, &usedAt)
if usedAt.Valid {
link.UsedAt = &usedAt.String
}
return link, err
}

View File

@ -0,0 +1,472 @@
package sqlite
import (
"context"
"testing"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func TestStoreChatThreadsSearchUploadsAndEvents(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
channels, err := st.ListChannels(ctx, workspace.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
channel := channels[0]
createdChannel, channelEvent, err := st.CreateChannel(ctx, store.CreateChannelInput{
WorkspaceID: workspace.ID,
Name: "Store Room",
UserID: owner.ID,
})
if err != nil {
t.Fatal(err)
}
if createdChannel.Name != "store-room" || channelEvent.Type != "channel.created" {
t.Fatalf("unexpected channel create result: %#v %#v", createdChannel, channelEvent)
}
root, event, err := st.CreateMessage(ctx, store.CreateMessageInput{
ChannelID: channel.ID,
AuthorID: owner.ID,
Body: "searchable **message**",
})
if err != nil {
t.Fatal(err)
}
if event.Type != "message.created" || event.ChannelID != channel.ID {
t.Fatalf("unexpected message event: %#v", event)
}
if root.ChannelSeq == nil || *root.ChannelSeq != 1 {
t.Fatalf("expected first channel sequence, got %#v", root.ChannelSeq)
}
messages, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(messages) != 1 || messages[0].ID != root.ID {
t.Fatalf("unexpected messages: %#v", messages)
}
after, err := st.ListMessages(ctx, channel.ID, owner.ID, *root.ChannelSeq, 10)
if err != nil {
t.Fatal(err)
}
if len(after) != 0 {
t.Fatalf("expected no messages after seq, got %#v", after)
}
reply, state, events, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{
RootMessageID: root.ID,
AuthorID: owner.ID,
Body: "reply body",
})
if err != nil {
t.Fatal(err)
}
if reply.ThreadSeq == nil || *reply.ThreadSeq != 1 || state.ReplyCount != 1 || len(events) != 2 {
t.Fatalf("unexpected reply result: %#v %#v %#v", reply, state, events)
}
threadRoot, replies, threadState, err := st.GetThread(ctx, root.ID, owner.ID, 10)
if err != nil {
t.Fatal(err)
}
if threadRoot.ID != root.ID || len(replies) != 1 || threadState.ReplyCount != 1 {
t.Fatalf("unexpected thread: %#v %#v %#v", threadRoot, replies, threadState)
}
results, err := st.SearchMessages(ctx, workspace.ID, owner.ID, "searchable", 10)
if err != nil {
t.Fatal(err)
}
if len(results) != 1 || results[0].Message.ID != root.ID {
t.Fatalf("unexpected search results: %#v", results)
}
eventsAfter, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, channelEvent.Cursor, 10)
if err != nil {
t.Fatal(err)
}
if len(eventsAfter) == 0 {
t.Fatal("expected events after channel cursor")
}
allEvents, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, "", 0)
if err != nil {
t.Fatal(err)
}
if len(allEvents) == 0 {
t.Fatal("expected events with empty cursor")
}
upload, err := st.CreateUpload(ctx, store.CreateUploadInput{
WorkspaceID: workspace.ID,
OwnerID: owner.ID,
Filename: "note.txt",
ContentType: "text/plain",
ByteSize: 4,
StoragePath: "/tmp/note.txt",
})
if err != nil {
t.Fatal(err)
}
gotUpload, err := st.GetUpload(ctx, upload.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
if gotUpload.ID != upload.ID || gotUpload.Filename != "note.txt" {
t.Fatalf("unexpected upload: %#v", gotUpload)
}
if err := st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: upload.ID, UserID: owner.ID}); err != nil {
t.Fatal(err)
}
withAttachment, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
if len(withAttachment[0].Attachments) != 1 {
t.Fatalf("expected attachment on message, got %#v", withAttachment[0])
}
added, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "claw"})
if err != nil {
t.Fatal(err)
}
removed, err := st.RemoveReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "claw"})
if err != nil {
t.Fatal(err)
}
if added.Type != "reaction.added" || removed.Type != "reaction.removed" {
t.Fatalf("unexpected reaction events: %#v %#v", added, removed)
}
}
func TestStoreAccessErrors(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "out@example.com"})
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "private"})
if err != nil {
t.Fatal(err)
}
upload, err := st.CreateUpload(ctx, store.CreateUploadInput{WorkspaceID: workspaces[0].ID, OwnerID: owner.ID, Filename: "x", ContentType: "text/plain", ByteSize: 1, StoragePath: "/tmp/x"})
if err != nil {
t.Fatal(err)
}
errorCases := []struct {
name string
fn func() error
}{
{"list workspaces outsider empty ok", func() error {
items, err := st.ListWorkspaces(ctx, outsider.ID)
if err != nil {
return err
}
if len(items) != 0 {
t.Fatalf("expected no workspaces for outsider, got %#v", items)
}
return nil
}},
{"get workspace denied", func() error {
_, err := st.GetWorkspace(ctx, workspaces[0].ID, outsider.ID)
return err
}},
{"list channels denied", func() error {
_, err := st.ListChannels(ctx, workspaces[0].ID, outsider.ID)
return err
}},
{"list messages denied", func() error {
_, err := st.ListMessages(ctx, channels[0].ID, outsider.ID, 0, 10)
return err
}},
{"thread denied", func() error {
_, _, _, err := st.GetThread(ctx, root.ID, outsider.ID, 10)
return err
}},
{"events denied", func() error {
_, err := st.ListEventsAfter(ctx, workspaces[0].ID, outsider.ID, "", 10)
return err
}},
{"upload denied", func() error {
_, err := st.GetUpload(ctx, upload.ID, outsider.ID)
return err
}},
{"attach denied", func() error {
return st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: upload.ID, UserID: outsider.ID})
}},
}
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
err := tc.fn()
if tc.name == "list workspaces outsider empty ok" {
if err != nil {
t.Fatal(err)
}
return
}
if err == nil {
t.Fatal("expected error")
}
})
}
}
func TestStoreDirectMessagesAndUserLookup(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
if got, err := st.GetUser(ctx, owner.ID); err != nil || got.ID != owner.ID {
t.Fatalf("unexpected user lookup: %#v err=%v", got, err)
}
other, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Other", Email: "other@example.com"})
if err != nil {
t.Fatal(err)
}
third, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Third", Email: "third@example.com"})
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
if err := st.AddWorkspaceMember(ctx, workspace.ID, other.ID, "member"); err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, third.ID, "member"); err != nil {
t.Fatal(err)
}
if _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{
WorkspaceID: workspace.ID,
UserID: owner.ID,
MemberIDs: []string{"", other.ID, other.ID},
}); err != nil {
t.Fatal(err)
}
dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{
WorkspaceID: workspace.ID,
UserID: owner.ID,
MemberIDs: []string{other.ID, third.ID},
})
if err != nil {
t.Fatal(err)
}
if len(dm.Members) != 3 {
t.Fatalf("expected three dm members, got %#v", dm.Members)
}
list, err := st.ListDirectConversations(ctx, workspace.ID, other.ID)
if err != nil {
t.Fatal(err)
}
if len(list) != 2 {
t.Fatalf("expected two dm conversations for other member, got %#v", list)
}
msg, event, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{
ConversationID: dm.ID,
AuthorID: other.ID,
Body: " direct hello ",
})
if err != nil {
t.Fatal(err)
}
if msg.DirectConversationID != dm.ID || event.Type != "message.created" || event.ChannelID != "" {
t.Fatalf("unexpected direct message result: %#v %#v", msg, event)
}
messages, err := st.ListDirectMessages(ctx, dm.ID, third.ID, 0, 0)
if err != nil {
t.Fatal(err)
}
if len(messages) != 1 || messages[0].Body != "direct hello" {
t.Fatalf("unexpected direct messages: %#v", messages)
}
after, err := st.ListDirectMessages(ctx, dm.ID, third.ID, *messages[0].ChannelSeq, 10)
if err != nil {
t.Fatal(err)
}
if len(after) != 0 {
t.Fatalf("expected no direct messages after seq, got %#v", after)
}
errorCases := []struct {
name string
fn func() error
}{
{"single member", func() error {
_, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID})
return err
}},
{"nonmember create dm", func() error {
outside, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outside", Email: "outside@example.com"})
if err != nil {
return err
}
_, err = st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{outside.ID}})
return err
}},
{"empty dm body", func() error {
_, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID})
return err
}},
{"missing dm", func() error {
_, err := st.ListDirectMessages(ctx, "dm_missing", owner.ID, 0, 10)
return err
}},
}
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
if err := tc.fn(); err == nil {
t.Fatal("expected error")
}
})
}
}
func TestStoreBranchCases(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
again, err := st.EnsureBootstrap(ctx, "Ignored", "ignored@example.com")
if err != nil {
t.Fatal(err)
}
if again.ID != owner.ID {
t.Fatalf("expected existing bootstrap user, got %#v", again)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
channels, err := st.ListChannels(ctx, workspace.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
channel := channels[0]
secondWorkspace, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "Other"}, owner.ID)
if err != nil {
t.Fatal(err)
}
defaultChannel, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: secondWorkspace.ID, UserID: owner.ID})
if err != nil {
t.Fatal(err)
}
if defaultChannel.Name != "general" || defaultChannel.Kind != "public" {
t.Fatalf("unexpected default channel: %#v", defaultChannel)
}
otherUpload, err := st.CreateUpload(ctx, store.CreateUploadInput{WorkspaceID: secondWorkspace.ID, OwnerID: owner.ID, Filename: "other", ContentType: "text/plain", ByteSize: 1, StoragePath: "/tmp/other"})
if err != nil {
t.Fatal(err)
}
root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root for branches"})
if err != nil {
t.Fatal(err)
}
if err := st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: otherUpload.ID, UserID: owner.ID}); err == nil {
t.Fatal("expected mismatched upload workspace error")
}
reply, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID, Body: "reply"})
if err != nil {
t.Fatal(err)
}
if _, _, _, err := st.GetThread(ctx, reply.ID, owner.ID, 10); err == nil {
t.Fatal("expected reply-as-root error")
}
if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: reply.ID, AuthorID: owner.ID, Body: "nested"}); err == nil {
t.Fatal("expected nested reply error")
}
if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID}); err == nil {
t.Fatal("expected empty reply body error")
}
if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: "chn_missing", AuthorID: owner.ID, Body: "x"}); err == nil {
t.Fatal("expected missing channel error")
}
if results, err := st.SearchMessages(ctx, workspace.ID, owner.ID, "missingterm", 999); err != nil || len(results) != 0 {
t.Fatalf("expected no search results, got %#v err=%v", results, err)
}
if _, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, "", 999); err != nil {
t.Fatal(err)
}
outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Branch Outsider", Email: "branch-out@example.com"})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateInvite(ctx, workspace.ID, outsider.ID); err == nil {
t.Fatal("expected invite membership error")
}
if _, err := st.SearchMessages(ctx, workspace.ID, outsider.ID, "root", 10); err == nil {
t.Fatal("expected search membership error")
}
firstLink, err := st.CreateMagicLink(ctx, "reuse@example.com", "Reuse One")
if err != nil {
t.Fatal(err)
}
firstUser, _, err := st.ConsumeMagicLink(ctx, firstLink.Token)
if err != nil {
t.Fatal(err)
}
secondLink, err := st.CreateMagicLink(ctx, "reuse@example.com", "Reuse Two")
if err != nil {
t.Fatal(err)
}
secondUser, _, err := st.ConsumeMagicLink(ctx, secondLink.Token)
if err != nil {
t.Fatal(err)
}
if firstUser.ID != secondUser.ID {
t.Fatalf("expected reused magic user, got %s and %s", firstUser.ID, secondUser.ID)
}
expired, err := st.CreateMagicLink(ctx, "expired@example.com", "Expired")
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `UPDATE auth_magic_links SET expires_at = '2000-01-01T00:00:00Z' WHERE token = ?`, expired.Token); err != nil {
t.Fatal(err)
}
if _, _, err := st.ConsumeMagicLink(ctx, expired.Token); err == nil {
t.Fatal("expected expired magic link error")
}
}

View File

@ -0,0 +1,201 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"slices"
"strings"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func (s *Store) ListDirectConversations(ctx context.Context, workspaceID, userID string) ([]store.DirectConversation, error) {
if err := s.requireMembership(ctx, workspaceID, userID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT dc.id, dc.workspace_id, dc.created_at
FROM direct_conversations dc
JOIN direct_conversation_members dcm ON dcm.conversation_id = dc.id
WHERE dc.workspace_id = ? AND dcm.user_id = ?
ORDER BY dc.created_at`, workspaceID, userID)
if err != nil {
return nil, err
}
out := []store.DirectConversation{}
for rows.Next() {
var dm store.DirectConversation
if err := rows.Scan(&dm.ID, &dm.WorkspaceID, &dm.CreatedAt); err != nil {
return nil, err
}
out = append(out, dm)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return nil, err
}
if err := rows.Close(); err != nil {
return nil, err
}
for i := range out {
members, err := s.directConversationMembers(ctx, out[i].ID)
if err != nil {
return nil, err
}
out[i].Members = members
}
return out, nil
}
func (s *Store) CreateDirectConversation(ctx context.Context, input store.CreateDirectConversationInput) (store.DirectConversation, error) {
if err := s.requireMembership(ctx, input.WorkspaceID, input.UserID); err != nil {
return store.DirectConversation{}, err
}
memberIDs := append([]string{input.UserID}, input.MemberIDs...)
memberIDs = compactStrings(memberIDs)
if len(memberIDs) < 2 {
return store.DirectConversation{}, errors.New("direct conversation needs at least two members")
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.DirectConversation{}, err
}
defer tx.Rollback()
for _, memberID := range memberIDs {
if err := requireMembershipTx(ctx, tx, input.WorkspaceID, memberID); err != nil {
return store.DirectConversation{}, err
}
}
dm := store.DirectConversation{ID: newID("dm"), WorkspaceID: input.WorkspaceID, CreatedAt: now()}
if _, err := tx.ExecContext(ctx, `INSERT INTO direct_conversations (id, workspace_id, created_at) VALUES (?, ?, ?)`, dm.ID, dm.WorkspaceID, dm.CreatedAt); err != nil {
return store.DirectConversation{}, err
}
for _, memberID := range memberIDs {
if _, err := tx.ExecContext(ctx, `INSERT INTO direct_conversation_members (conversation_id, user_id, created_at) VALUES (?, ?, ?)`, dm.ID, memberID, dm.CreatedAt); err != nil {
return store.DirectConversation{}, err
}
}
if err := tx.Commit(); err != nil {
return store.DirectConversation{}, err
}
members, err := s.directConversationMembers(ctx, dm.ID)
if err != nil {
return store.DirectConversation{}, err
}
dm.Members = members
return dm, nil
}
func (s *Store) ListDirectMessages(ctx context.Context, conversationID, userID string, afterSeq int64, limit int) ([]store.Message, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
if err := s.requireDirectMembership(ctx, conversationID, userID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq,
m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at,
u.id, u.display_name, u.avatar_url, u.created_at
FROM messages m
JOIN users u ON u.id = m.author_id
WHERE m.direct_conversation_id = ? AND m.channel_seq > ?
ORDER BY m.channel_seq
LIMIT ?`, conversationID, afterSeq, limit)
if err != nil {
return nil, err
}
defer rows.Close()
messages, err := scanMessages(rows)
if err != nil {
return nil, err
}
return s.hydrateAttachments(ctx, messages)
}
func (s *Store) CreateDirectMessage(ctx context.Context, input store.CreateDirectMessageInput) (store.Message, store.Event, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Message{}, store.Event{}, err
}
defer tx.Rollback()
var workspaceID string
if err := tx.QueryRowContext(ctx, `SELECT workspace_id FROM direct_conversations WHERE id = ?`, input.ConversationID).Scan(&workspaceID); err != nil {
return store.Message{}, store.Event{}, err
}
if err := requireDirectMembershipTx(ctx, tx, input.ConversationID, input.AuthorID); err != nil {
return store.Message{}, store.Event{}, err
}
var seq int64
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(channel_seq), 0) + 1 FROM messages WHERE direct_conversation_id = ?`, input.ConversationID).Scan(&seq); err != nil {
return store.Message{}, store.Event{}, err
}
id := newID("msg")
createdAt := now()
body := strings.TrimSpace(input.Body)
if body == "" {
return store.Message{}, store.Event{}, errors.New("message body is required")
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at)
VALUES (?, ?, NULL, ?, ?, NULL, ?, ?, NULL, ?, 'markdown', ?)`, id, workspaceID, input.ConversationID, input.AuthorID, id, seq, body, createdAt); err != nil {
return store.Message{}, store.Event{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO thread_state (root_message_id) VALUES (?)`, id); err != nil {
return store.Message{}, store.Event{}, err
}
event, err := insertEvent(ctx, tx, workspaceID, "", "message.created", &seq, map[string]string{"message_id": id, "direct_conversation_id": input.ConversationID})
if err != nil {
return store.Message{}, store.Event{}, err
}
msg, err := getMessageTx(ctx, tx, id)
if err != nil {
return store.Message{}, store.Event{}, err
}
return msg, event, tx.Commit()
}
func (s *Store) requireDirectMembership(ctx context.Context, conversationID, userID string) error {
var one int
return s.db.QueryRowContext(ctx, `SELECT 1 FROM direct_conversation_members WHERE conversation_id = ? AND user_id = ?`, conversationID, userID).Scan(&one)
}
func requireDirectMembershipTx(ctx context.Context, tx *sql.Tx, conversationID, userID string) error {
var one int
return tx.QueryRowContext(ctx, `SELECT 1 FROM direct_conversation_members WHERE conversation_id = ? AND user_id = ?`, conversationID, userID).Scan(&one)
}
func (s *Store) directConversationMembers(ctx context.Context, conversationID string) ([]store.User, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, u.display_name, u.avatar_url, u.created_at
FROM users u
JOIN direct_conversation_members dcm ON dcm.user_id = u.id
WHERE dcm.conversation_id = ?
ORDER BY u.display_name`, conversationID)
if err != nil {
return nil, err
}
defer rows.Close()
members := []store.User{}
for rows.Next() {
member, err := scanUser(rows)
if err != nil {
return nil, err
}
members = append(members, member)
}
return members, rows.Err()
}
func compactStrings(values []string) []string {
var out []string
for _, value := range values {
value = strings.TrimSpace(value)
if value == "" || slices.Contains(out, value) {
continue
}
out = append(out, value)
}
return out
}

View File

@ -0,0 +1,67 @@
package sqlite
import (
"context"
"database/sql"
"encoding/json"
"io"
)
func (s *Store) Backup(ctx context.Context, outPath string) error {
_, err := s.db.ExecContext(ctx, `VACUUM INTO ?`, outPath)
return err
}
func (s *Store) ExportJSON(ctx context.Context, writer io.Writer) error {
out := map[string]any{}
tables := []string{
"users", "identities", "workspaces", "workspace_members", "channels",
"messages", "thread_state", "reactions", "events", "uploads",
"message_attachments", "direct_conversations", "direct_conversation_members",
"invites", "auth_magic_links", "sessions",
}
for _, table := range tables {
rows, err := s.db.QueryContext(ctx, `SELECT * FROM `+table)
if err != nil {
return err
}
values, err := rowsToMaps(rows)
if err != nil {
return err
}
out[table] = values
}
encoder := json.NewEncoder(writer)
encoder.SetIndent("", " ")
return encoder.Encode(out)
}
func rowsToMaps(rows *sql.Rows) ([]map[string]any, error) {
defer rows.Close()
cols, err := rows.Columns()
if err != nil {
return nil, err
}
out := []map[string]any{}
for rows.Next() {
values := make([]any, len(cols))
scan := make([]any, len(cols))
for i := range values {
scan[i] = &values[i]
}
if err := rows.Scan(scan...); err != nil {
return nil, err
}
row := map[string]any{}
for i, col := range cols {
switch value := values[i].(type) {
case []byte:
row[col] = string(value)
default:
row[col] = value
}
}
out = append(out, row)
}
return out, rows.Err()
}

View File

@ -0,0 +1,335 @@
package sqlite
import (
"context"
"testing"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func TestStoreFaultBranches(t *testing.T) {
t.Parallel()
t.Run("event insert failure", func(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, _ := seededStore(t)
if _, err := st.db.ExecContext(ctx, `DROP TABLE events`); err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: workspace.ID, UserID: owner.ID, Name: "events-down"}); err == nil {
t.Fatal("expected event insert failure")
}
})
t.Run("thread state failures", func(t *testing.T) {
t.Parallel()
ctx, st, owner, _, channel := seededStore(t)
root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root"})
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE thread_state`); err != nil {
t.Fatal(err)
}
if _, _, _, err := st.GetThread(ctx, root.ID, owner.ID, 10); err == nil {
t.Fatal("expected get thread state failure")
}
if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: root.ID, AuthorID: owner.ID, Body: "reply"}); err == nil {
t.Fatal("expected update thread state failure")
}
})
t.Run("attachment hydration failure", func(t *testing.T) {
t.Parallel()
ctx, st, owner, _, channel := seededStore(t)
if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root"}); err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE message_attachments`); err != nil {
t.Fatal(err)
}
if _, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 10); err == nil {
t.Fatal("expected attachment hydration failure")
}
})
t.Run("direct conversation query failure", func(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, _ := seededStore(t)
if _, err := st.db.ExecContext(ctx, `DROP TABLE direct_conversation_members`); err != nil {
t.Fatal(err)
}
if _, err := st.ListDirectConversations(ctx, workspace.ID, owner.ID); err == nil {
t.Fatal("expected direct conversation query failure")
}
if _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{owner.ID}}); err == nil {
t.Fatal("expected direct conversation membership failure")
}
})
t.Run("direct member hydration failure", func(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, _ := seededStore(t)
member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "member-hydrate@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, "member"); err != nil {
t.Fatal(err)
}
if _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{member.ID}}); err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE users`); err != nil {
t.Fatal(err)
}
if _, err := st.ListDirectConversations(ctx, workspace.ID, owner.ID); err == nil {
t.Fatal("expected direct member hydration failure")
}
})
t.Run("upload query failure", func(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, channel := seededStore(t)
root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root"})
if err != nil {
t.Fatal(err)
}
upload, err := st.CreateUpload(ctx, store.CreateUploadInput{WorkspaceID: workspace.ID, OwnerID: owner.ID, Filename: "x", ContentType: "text/plain", ByteSize: 1, StoragePath: "/tmp/x"})
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE uploads`); err != nil {
t.Fatal(err)
}
if _, err := st.GetUpload(ctx, upload.ID, owner.ID); err == nil {
t.Fatal("expected get upload failure")
}
if err := st.AttachUpload(ctx, store.AttachUploadInput{MessageID: root.ID, UploadID: upload.ID, UserID: owner.ID}); err == nil {
t.Fatal("expected attach upload failure")
}
})
t.Run("bad magic link expiration", func(t *testing.T) {
t.Parallel()
ctx, st, _, _, _ := seededStore(t)
link, err := st.CreateMagicLink(ctx, "bad-expiry@example.com", "Bad")
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `UPDATE auth_magic_links SET expires_at = 'bad' WHERE token = ?`, link.Token); err != nil {
t.Fatal(err)
}
if _, _, err := st.ConsumeMagicLink(ctx, link.Token); err == nil {
t.Fatal("expected bad expiry error")
}
if _, _, err := st.ConsumeMagicLink(ctx, "missing"); err == nil {
t.Fatal("expected missing link error")
}
})
t.Run("magic link session failure", func(t *testing.T) {
t.Parallel()
ctx, st, _, _, _ := seededStore(t)
link, err := st.CreateMagicLink(ctx, "session-fail@example.com", "Session")
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE sessions`); err != nil {
t.Fatal(err)
}
if _, _, err := st.ConsumeMagicLink(ctx, link.Token); err == nil {
t.Fatal("expected session create failure")
}
})
t.Run("duplicate local identity", func(t *testing.T) {
t.Parallel()
ctx, st, _, _, _ := seededStore(t)
if _, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "One", Email: "dupe@example.com"}); err != nil {
t.Fatal(err)
}
if _, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Two", Email: "dupe@example.com"}); err == nil {
t.Fatal("expected duplicate identity error")
}
})
t.Run("message table failures", func(t *testing.T) {
t.Parallel()
ctx, st, owner, _, channel := seededStore(t)
if _, err := st.db.ExecContext(ctx, `DROP TABLE messages`); err != nil {
t.Fatal(err)
}
if _, err := st.ListMessages(ctx, channel.ID, owner.ID, 0, 10); err == nil {
t.Fatal("expected list messages failure")
}
if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "x"}); err == nil {
t.Fatal("expected create message sequence failure")
}
if _, _, _, err := st.GetThread(ctx, "msg_missing", owner.ID, 10); err == nil {
t.Fatal("expected get thread message failure")
}
})
t.Run("message transaction failures", func(t *testing.T) {
t.Parallel()
ctx, st, owner, _, channel := seededStore(t)
if _, err := st.db.ExecContext(ctx, `DROP TABLE thread_state`); err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "x"}); err == nil {
t.Fatal("expected message thread-state failure")
}
})
t.Run("message event failure", func(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, channel := seededStore(t)
if _, err := st.db.ExecContext(ctx, `DROP TABLE events`); err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "x"}); err == nil {
t.Fatal("expected message event failure")
}
if _, err := st.ListEventsAfter(ctx, workspace.ID, owner.ID, "", 10); err == nil {
t.Fatal("expected list events failure")
}
})
t.Run("channel and workspace write failures", func(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, _ := seededStore(t)
outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "outsider@example.com"})
if err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: workspace.ID, UserID: outsider.ID, Name: "denied"}); err == nil {
t.Fatal("expected channel membership failure")
}
if _, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "No Owner"}, "usr_missing"); err == nil {
t.Fatal("expected workspace member foreign-key failure")
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE channels`); err != nil {
t.Fatal(err)
}
if _, err := st.ListChannels(ctx, workspace.ID, owner.ID); err == nil {
t.Fatal("expected list channels failure")
}
})
t.Run("reaction failures", func(t *testing.T) {
t.Parallel()
ctx, st, owner, _, channel := seededStore(t)
root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "root"})
if err != nil {
t.Fatal(err)
}
outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "out@example.com"})
if err != nil {
t.Fatal(err)
}
if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: outsider.ID, Emoji: "x"}); err == nil {
t.Fatal("expected reaction membership failure")
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE reactions`); err != nil {
t.Fatal(err)
}
if _, err := st.AddReaction(ctx, store.CreateReactionInput{MessageID: root.ID, UserID: owner.ID, Emoji: "x"}); err == nil {
t.Fatal("expected reaction write failure")
}
})
t.Run("direct message failures", func(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, _ := seededStore(t)
member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "member@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, "member"); err != nil {
t.Fatal(err)
}
dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{member.ID}})
if err != nil {
t.Fatal(err)
}
outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "dmout@example.com"})
if err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: outsider.ID, Body: "x"}); err == nil {
t.Fatal("expected direct message membership failure")
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE messages`); err != nil {
t.Fatal(err)
}
if _, err := st.ListDirectMessages(ctx, dm.ID, owner.ID, 0, 10); err == nil {
t.Fatal("expected direct list failure")
}
if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID, Body: "x"}); err == nil {
t.Fatal("expected direct message sequence failure")
}
})
t.Run("direct message transaction failures", func(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, _ := seededStore(t)
member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "direct-tx@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, "member"); err != nil {
t.Fatal(err)
}
dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{member.ID}})
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE thread_state`); err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID, Body: "x"}); err == nil {
t.Fatal("expected direct thread-state failure")
}
})
t.Run("direct message event failure", func(t *testing.T) {
t.Parallel()
ctx, st, owner, workspace, _ := seededStore(t)
member, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Member", Email: "direct-event@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, member.ID, "member"); err != nil {
t.Fatal(err)
}
dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{member.ID}})
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE events`); err != nil {
t.Fatal(err)
}
if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID, Body: "x"}); err == nil {
t.Fatal("expected direct event failure")
}
})
}
func seededStore(t *testing.T) (context.Context, *Store, store.User, store.Workspace, store.Channel) {
t.Helper()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
return ctx, st, owner, workspaces[0], channels[0]
}

View File

@ -0,0 +1,198 @@
package sqlite
import (
"context"
"crypto/rand"
"database/sql"
"encoding/json"
"regexp"
"strings"
"time"
"github.com/oklog/ulid/v2"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
type scanner interface {
Scan(dest ...any) error
}
func scanUser(row scanner) (store.User, error) {
var u store.User
err := row.Scan(&u.ID, &u.DisplayName, &u.AvatarURL, &u.CreatedAt)
return u, err
}
func scanWorkspace(row scanner) (store.Workspace, error) {
var w store.Workspace
err := row.Scan(&w.ID, &w.Name, &w.Slug, &w.CreatedAt)
return w, err
}
func scanChannel(row scanner) (store.Channel, error) {
var ch store.Channel
err := row.Scan(&ch.ID, &ch.WorkspaceID, &ch.Name, &ch.Kind, &ch.CreatedAt, &ch.ArchivedAt)
return ch, err
}
func getMessage(ctx context.Context, db *sql.DB, id string) (store.Message, error) {
return scanMessage(db.QueryRowContext(ctx, messageSelect()+` WHERE m.id = ?`, id))
}
func getMessageTx(ctx context.Context, tx *sql.Tx, id string) (store.Message, error) {
return scanMessage(tx.QueryRowContext(ctx, messageSelect()+` WHERE m.id = ?`, id))
}
func messageSelect() string {
return `SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq,
m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at,
u.id, u.display_name, u.avatar_url, u.created_at
FROM messages m
JOIN users u ON u.id = m.author_id`
}
func scanMessage(row scanner) (store.Message, error) {
var m store.Message
var parent, edited, deleted sql.NullString
var channelSeq, threadSeq sql.NullInt64
var author store.User
err := row.Scan(&m.ID, &m.WorkspaceID, &m.ChannelID, &m.DirectConversationID, &m.AuthorID, &parent, &m.ThreadRootID, &channelSeq, &threadSeq, &m.Body, &m.BodyFormat, &m.CreatedAt, &edited, &deleted, &author.ID, &author.DisplayName, &author.AvatarURL, &author.CreatedAt)
if err != nil {
return store.Message{}, err
}
if parent.Valid {
m.ParentMessageID = &parent.String
}
if channelSeq.Valid {
m.ChannelSeq = &channelSeq.Int64
}
if threadSeq.Valid {
m.ThreadSeq = &threadSeq.Int64
}
if edited.Valid {
m.EditedAt = &edited.String
}
if deleted.Valid {
m.DeletedAt = &deleted.String
}
m.Author = &author
return m, nil
}
func scanMessages(rows *sql.Rows) ([]store.Message, error) {
out := []store.Message{}
for rows.Next() {
msg, err := scanMessage(rows)
if err != nil {
return nil, err
}
out = append(out, msg)
}
return out, rows.Err()
}
func getThreadState(ctx context.Context, db *sql.DB, rootID string) (store.ThreadState, error) {
return scanThreadState(db.QueryRowContext(ctx, `SELECT root_message_id, reply_count, last_reply_at, last_reply_author_ids_json FROM thread_state WHERE root_message_id = ?`, rootID))
}
func scanThreadState(row scanner) (store.ThreadState, error) {
var state store.ThreadState
var lastReply sql.NullString
if err := row.Scan(&state.RootMessageID, &state.ReplyCount, &lastReply, &state.LastReplyAuthorIDsJSON); err != nil {
return store.ThreadState{}, err
}
if lastReply.Valid {
state.LastReplyAt = &lastReply.String
}
_ = json.Unmarshal([]byte(state.LastReplyAuthorIDsJSON), &state.LastReplyAuthorIDs)
return state, nil
}
func updateThreadState(ctx context.Context, tx *sql.Tx, rootID, authorID, createdAt string) (store.ThreadState, error) {
state, err := scanThreadState(tx.QueryRowContext(ctx, `SELECT root_message_id, reply_count, last_reply_at, last_reply_author_ids_json FROM thread_state WHERE root_message_id = ?`, rootID))
if err != nil {
return store.ThreadState{}, err
}
ids := append([]string{authorID}, state.LastReplyAuthorIDs...)
seen := map[string]bool{}
compact := make([]string, 0, 3)
for _, id := range ids {
if seen[id] {
continue
}
seen[id] = true
compact = append(compact, id)
if len(compact) == 3 {
break
}
}
body, _ := json.Marshal(compact)
if _, err := tx.ExecContext(ctx, `UPDATE thread_state SET reply_count = reply_count + 1, last_reply_at = ?, last_reply_author_ids_json = ? WHERE root_message_id = ?`, createdAt, string(body), rootID); err != nil {
return store.ThreadState{}, err
}
return scanThreadState(tx.QueryRowContext(ctx, `SELECT root_message_id, reply_count, last_reply_at, last_reply_author_ids_json FROM thread_state WHERE root_message_id = ?`, rootID))
}
func insertEvent(ctx context.Context, tx *sql.Tx, workspaceID, channelID, eventType string, seq *int64, payload any) (store.Event, error) {
payloadJSON, err := json.Marshal(payload)
if err != nil {
return store.Event{}, err
}
event := store.Event{
ID: newID("evt"),
Cursor: newID("cur"),
Type: eventType,
WorkspaceID: workspaceID,
ChannelID: channelID,
Seq: seq,
CreatedAt: now(),
PayloadJSON: string(payloadJSON),
Payload: payload,
}
if _, err := tx.ExecContext(ctx, `INSERT INTO events (id, cursor, workspace_id, channel_id, type, seq, payload_json, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, event.ID, event.Cursor, event.WorkspaceID, nullableString(event.ChannelID), event.Type, event.Seq, event.PayloadJSON, event.CreatedAt); err != nil {
return store.Event{}, err
}
return event, nil
}
func scanEvents(rows *sql.Rows) ([]store.Event, error) {
out := []store.Event{}
for rows.Next() {
var event store.Event
var seq sql.NullInt64
if err := rows.Scan(&event.ID, &event.Cursor, &event.WorkspaceID, &event.ChannelID, &event.Type, &seq, &event.PayloadJSON, &event.CreatedAt); err != nil {
return nil, err
}
if seq.Valid {
event.Seq = &seq.Int64
}
var payload any
_ = json.Unmarshal([]byte(event.PayloadJSON), &payload)
event.Payload = payload
out = append(out, event)
}
return out, rows.Err()
}
func nullableString(value string) any {
if value == "" {
return nil
}
return value
}
func newID(prefix string) string {
return prefix + "_" + strings.ToLower(ulid.MustNew(ulid.Timestamp(time.Now()), rand.Reader).String())
}
func now() string {
return time.Now().UTC().Format(time.RFC3339Nano)
}
var slugRE = regexp.MustCompile(`[^a-z0-9]+`)
func slug(value string) string {
value = strings.ToLower(strings.TrimSpace(value))
value = slugRE.ReplaceAllString(value, "-")
return strings.Trim(value, "-")
}

View File

@ -0,0 +1,56 @@
package sqlite
import (
"context"
"database/sql"
"errors"
"strings"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func (s *Store) UpsertIdentityUser(ctx context.Context, input store.UpsertIdentityUserInput) (store.User, error) {
provider := strings.TrimSpace(input.Provider)
subject := strings.TrimSpace(input.ProviderSubject)
if provider == "" || subject == "" {
return store.User{}, errors.New("identity provider and subject are required")
}
user, err := scanUser(s.db.QueryRowContext(ctx, `
SELECT u.id, u.display_name, u.avatar_url, u.created_at
FROM identities i
JOIN users u ON u.id = i.user_id
WHERE i.provider = ? AND i.provider_subject = ?`, provider, subject))
if err == nil {
return user, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return store.User{}, err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.User{}, err
}
defer tx.Rollback()
user = store.User{
ID: newID("usr"),
DisplayName: strings.TrimSpace(input.DisplayName),
AvatarURL: strings.TrimSpace(input.AvatarURL),
CreatedAt: now(),
}
if user.DisplayName == "" {
user.DisplayName = strings.TrimSpace(input.Email)
}
if user.DisplayName == "" {
user.DisplayName = provider + ":" + subject
}
if _, err := tx.ExecContext(ctx, `INSERT INTO users (id, display_name, avatar_url, created_at) VALUES (?, ?, ?, ?)`, user.ID, user.DisplayName, user.AvatarURL, user.CreatedAt); err != nil {
return store.User{}, err
}
_, err = tx.ExecContext(ctx, `
INSERT INTO identities (id, user_id, provider, provider_subject, email, created_at)
VALUES (?, ?, ?, ?, ?, ?)`, newID("idn"), user.ID, provider, subject, strings.TrimSpace(input.Email), user.CreatedAt)
if err != nil {
return store.User{}, err
}
return user, tx.Commit()
}

View File

@ -0,0 +1,24 @@
package sqlite
import (
"context"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func (s *Store) CreateInvite(ctx context.Context, workspaceID, createdBy string) (store.Invite, error) {
if err := s.requireMembership(ctx, workspaceID, createdBy); err != nil {
return store.Invite{}, err
}
invite := store.Invite{
ID: newID("inv"),
WorkspaceID: workspaceID,
Token: newID("tok"),
CreatedBy: createdBy,
CreatedAt: now(),
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO invites (id, workspace_id, token, created_by, created_at)
VALUES (?, ?, ?, ?, ?)`, invite.ID, invite.WorkspaceID, invite.Token, invite.CreatedBy, invite.CreatedAt)
return invite, err
}

View File

@ -0,0 +1,13 @@
package sqlite
import "context"
func (s *Store) AddWorkspaceMember(ctx context.Context, workspaceID, userID, role string) error {
if role == "" {
role = "member"
}
_, err := s.db.ExecContext(ctx, `
INSERT OR IGNORE INTO workspace_members (workspace_id, user_id, role, created_at)
VALUES (?, ?, ?, ?)`, workspaceID, userID, role, now())
return err
}

View File

@ -0,0 +1,150 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS identities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_subject TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE(provider, provider_subject)
);
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_members (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, user_id)
);
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL,
archived_at TEXT,
UNIQUE(workspace_id, name)
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
channel_id TEXT REFERENCES channels(id) ON DELETE CASCADE,
direct_conversation_id TEXT,
author_id TEXT NOT NULL REFERENCES users(id),
parent_message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
thread_root_id TEXT NOT NULL,
channel_seq INTEGER,
thread_seq INTEGER,
body TEXT NOT NULL,
body_format TEXT NOT NULL,
created_at TEXT NOT NULL,
edited_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_seq ON messages(channel_id, channel_seq);
CREATE INDEX IF NOT EXISTS idx_messages_thread_seq ON messages(thread_root_id, thread_seq);
CREATE INDEX IF NOT EXISTS idx_messages_direct_seq ON messages(direct_conversation_id, channel_seq);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
message_id UNINDEXED,
workspace_id UNINDEXED,
body,
tokenize = 'porter unicode61'
);
CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE message_id = old.id;
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE OF body ON messages BEGIN
DELETE FROM messages_fts WHERE message_id = old.id;
INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body);
END;
CREATE TABLE IF NOT EXISTS thread_state (
root_message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
reply_count INTEGER NOT NULL DEFAULT 0,
last_reply_at TEXT,
last_reply_author_ids_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS reactions (
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
cursor TEXT NOT NULL UNIQUE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
channel_id TEXT,
type TEXT NOT NULL,
seq INTEGER,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_workspace_cursor ON events(workspace_id, cursor);
CREATE TABLE IF NOT EXISTS uploads (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
byte_size INTEGER NOT NULL,
storage_path TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS message_attachments (
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
upload_id TEXT NOT NULL REFERENCES uploads(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, upload_id)
);
CREATE TABLE IF NOT EXISTS direct_conversations (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS direct_conversation_members (
conversation_id TEXT NOT NULL REFERENCES direct_conversations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (conversation_id, user_id)
);
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
accepted_at TEXT
);

View File

@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS auth_magic_links (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_auth_magic_links_token ON auth_magic_links(token);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);

View File

@ -0,0 +1,156 @@
package sqlite
import (
"context"
"path/filepath"
"testing"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func TestStoreMiscBranches(t *testing.T) {
t.Parallel()
ctx := context.Background()
raw, err := Open(filepath.Join(t.TempDir(), "raw.db"))
if err != nil {
t.Fatal(err)
}
if err := raw.Close(); err != nil {
t.Fatal(err)
}
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
unnamed, err := st.CreateUser(ctx, store.CreateUserInput{})
if err != nil {
t.Fatal(err)
}
if unnamed.DisplayName != "Local User" {
t.Fatalf("unexpected default user: %#v", unnamed)
}
identityUser, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{
Provider: "github",
ProviderSubject: "42",
Email: "octo@example.com",
DisplayName: "Octo",
AvatarURL: "https://example.com/a.png",
})
if err != nil {
t.Fatal(err)
}
againIdentity, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{Provider: "github", ProviderSubject: "42"})
if err != nil {
t.Fatal(err)
}
if againIdentity.ID != identityUser.ID {
t.Fatalf("expected existing identity user, got %#v", againIdentity)
}
session, err := st.CreateSession(ctx, identityUser.ID)
if err != nil {
t.Fatal(err)
}
if session.UserID != identityUser.ID || session.Token == "" {
t.Fatalf("unexpected session: %#v", session)
}
if _, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{}); err == nil {
t.Fatal("expected missing identity error")
}
fallbackIdentity, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{Provider: "github", ProviderSubject: "fallback"})
if err != nil {
t.Fatal(err)
}
if fallbackIdentity.DisplayName != "github:fallback" {
t.Fatalf("unexpected fallback identity display: %#v", fallbackIdentity)
}
emailIdentity, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{Provider: "github", ProviderSubject: "email", Email: "email@example.com"})
if err != nil {
t.Fatal(err)
}
if emailIdentity.DisplayName != "email@example.com" {
t.Fatalf("unexpected email identity display: %#v", emailIdentity)
}
if _, err := st.CreateSession(ctx, "usr_missing"); err == nil {
t.Fatal("expected missing session user error")
}
untitled, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{}, owner.ID)
if err != nil {
t.Fatal(err)
}
if untitled.Name != "Untitled" || untitled.Slug != "untitled" {
t.Fatalf("unexpected default workspace: %#v", untitled)
}
if err := st.AddWorkspaceMember(ctx, untitled.ID, unnamed.ID, ""); err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
root, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "edited root"})
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `UPDATE messages SET edited_at = created_at, deleted_at = created_at WHERE id = ?`, root.ID); err != nil {
t.Fatal(err)
}
messages, err := st.ListMessages(ctx, channels[0].ID, owner.ID, 0, 10)
if err != nil {
t.Fatal(err)
}
if messages[0].EditedAt == nil || messages[0].DeletedAt == nil {
t.Fatalf("expected edited/deleted fields, got %#v", messages[0])
}
authors := []store.User{owner}
for _, name := range []string{"One", "Two", "Three", "Four"} {
user, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: name, Email: name + "@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspaces[0].ID, user.ID, "member"); err != nil {
t.Fatal(err)
}
authors = append(authors, user)
}
var reply store.Message
for i, author := range []store.User{authors[0], authors[1], authors[0], authors[2], authors[3], authors[4]} {
reply, _, _, err = st.CreateThreadReply(ctx, store.CreateThreadReplyInput{
RootMessageID: root.ID,
AuthorID: author.ID,
Body: "reply searchable",
})
if err != nil {
t.Fatalf("reply %d: %v", i, err)
}
}
if _, err := st.db.ExecContext(ctx, `UPDATE messages SET edited_at = created_at, deleted_at = created_at WHERE id = ?`, reply.ID); err != nil {
t.Fatal(err)
}
_, replies, threadState, err := st.GetThread(ctx, root.ID, owner.ID, 10)
if err != nil {
t.Fatal(err)
}
if _, _, _, err := st.GetThread(ctx, root.ID, owner.ID, 0); err != nil {
t.Fatal(err)
}
if len(replies) != 6 || len(threadState.LastReplyAuthorIDs) != 3 {
t.Fatalf("unexpected thread compaction: replies=%d state=%#v", len(replies), threadState)
}
if replies[len(replies)-1].EditedAt == nil || replies[len(replies)-1].DeletedAt == nil {
t.Fatalf("expected edited/deleted reply fields, got %#v", replies[len(replies)-1])
}
results, err := st.SearchMessages(ctx, workspaces[0].ID, owner.ID, "reply", 10)
if err != nil {
t.Fatal(err)
}
if len(results) == 0 || results[0].Message.ParentMessageID == nil || results[0].Message.ThreadSeq == nil {
t.Fatalf("expected reply search result with thread fields, got %#v", results)
}
}

View File

@ -0,0 +1,120 @@
package sqlite
import (
"context"
"errors"
"strings"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func (s *Store) UpdateChannel(ctx context.Context, input store.UpdateChannelInput) (store.Channel, store.Event, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Channel{}, store.Event{}, err
}
defer tx.Rollback()
ch, err := scanChannel(tx.QueryRowContext(ctx, `SELECT id, workspace_id, name, kind, created_at, archived_at FROM channels WHERE id = ?`, input.ChannelID))
if err != nil {
return store.Channel{}, store.Event{}, err
}
if err := requireMembershipTx(ctx, tx, ch.WorkspaceID, input.UserID); err != nil {
return store.Channel{}, store.Event{}, err
}
name := slug(input.Name)
if name == "" {
name = ch.Name
}
kind := strings.TrimSpace(input.Kind)
if kind == "" {
kind = ch.Kind
}
archivedValue := ch.ArchivedAt
if input.Archived != nil {
archivedValue = nil
if *input.Archived {
value := now()
archivedValue = &value
}
}
var archivedAt any
if archivedValue != nil {
archivedAt = *archivedValue
}
if _, err := tx.ExecContext(ctx, `UPDATE channels SET name = ?, kind = ?, archived_at = ? WHERE id = ?`, name, kind, archivedAt, ch.ID); err != nil {
return store.Channel{}, store.Event{}, err
}
event, err := insertEvent(ctx, tx, ch.WorkspaceID, ch.ID, "channel.updated", nil, map[string]string{"channel_id": ch.ID})
if err != nil {
return store.Channel{}, store.Event{}, err
}
ch.Name = name
ch.Kind = kind
ch.ArchivedAt = archivedValue
return ch, event, tx.Commit()
}
func (s *Store) UpdateMessage(ctx context.Context, input store.UpdateMessageInput) (store.Message, store.Event, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Message{}, store.Event{}, err
}
defer tx.Rollback()
msg, err := getMessageTx(ctx, tx, input.MessageID)
if err != nil {
return store.Message{}, store.Event{}, err
}
if err := requireMembershipTx(ctx, tx, msg.WorkspaceID, input.UserID); err != nil {
return store.Message{}, store.Event{}, err
}
body := strings.TrimSpace(input.Body)
if body == "" {
return store.Message{}, store.Event{}, errors.New("message body is required")
}
editedAt := now()
if _, err := tx.ExecContext(ctx, `UPDATE messages SET body = ?, edited_at = ? WHERE id = ?`, body, editedAt, msg.ID); err != nil {
return store.Message{}, store.Event{}, err
}
payload := messagePayload(msg)
event, err := insertEvent(ctx, tx, msg.WorkspaceID, msg.ChannelID, "message.updated", msg.ChannelSeq, payload)
if err != nil {
return store.Message{}, store.Event{}, err
}
msg.Body = body
msg.EditedAt = &editedAt
return msg, event, tx.Commit()
}
func (s *Store) DeleteMessage(ctx context.Context, input store.DeleteMessageInput) (store.Message, store.Event, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Message{}, store.Event{}, err
}
defer tx.Rollback()
msg, err := getMessageTx(ctx, tx, input.MessageID)
if err != nil {
return store.Message{}, store.Event{}, err
}
if err := requireMembershipTx(ctx, tx, msg.WorkspaceID, input.UserID); err != nil {
return store.Message{}, store.Event{}, err
}
deletedAt := now()
if _, err := tx.ExecContext(ctx, `UPDATE messages SET body = '', deleted_at = ? WHERE id = ?`, deletedAt, msg.ID); err != nil {
return store.Message{}, store.Event{}, err
}
event, err := insertEvent(ctx, tx, msg.WorkspaceID, msg.ChannelID, "message.deleted", msg.ChannelSeq, messagePayload(msg))
if err != nil {
return store.Message{}, store.Event{}, err
}
msg.Body = ""
msg.DeletedAt = &deletedAt
return msg, event, tx.Commit()
}
func messagePayload(msg store.Message) map[string]string {
payload := map[string]string{"message_id": msg.ID, "root_message_id": msg.ThreadRootID}
if msg.DirectConversationID != "" {
payload["direct_conversation_id"] = msg.DirectConversationID
}
return payload
}

View File

@ -0,0 +1,182 @@
package sqlite
import (
"context"
"testing"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func TestMutationsCreateDurableEvents(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
channel := channels[0]
archived := true
updatedChannel, channelEvent, err := st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: channel.ID, UserID: owner.ID, Name: "harbor", Archived: &archived})
if err != nil {
t.Fatal(err)
}
if updatedChannel.Name != "harbor" || updatedChannel.ArchivedAt == nil || channelEvent.Type != "channel.updated" {
t.Fatalf("unexpected channel update: %#v %#v", updatedChannel, channelEvent)
}
archived = false
updatedChannel, _, err = st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: channel.ID, UserID: owner.ID, Kind: "private", Archived: &archived})
if err != nil {
t.Fatal(err)
}
if updatedChannel.Kind != "private" || updatedChannel.ArchivedAt != nil {
t.Fatalf("unexpected channel unarchive: %#v", updatedChannel)
}
message, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID, Body: "before"})
if err != nil {
t.Fatal(err)
}
updatedMessage, updateEvent, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: message.ID, UserID: owner.ID, Body: "after"})
if err != nil {
t.Fatal(err)
}
if updatedMessage.Body != "after" || updatedMessage.EditedAt == nil || updateEvent.Type != "message.updated" {
t.Fatalf("unexpected message update: %#v %#v", updatedMessage, updateEvent)
}
deletedMessage, deleteEvent, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: message.ID, UserID: owner.ID})
if err != nil {
t.Fatal(err)
}
if deletedMessage.DeletedAt == nil || deleteEvent.Type != "message.deleted" {
t.Fatalf("unexpected message delete: %#v %#v", deletedMessage, deleteEvent)
}
second, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Second", Email: "second@example.com"})
if err != nil {
t.Fatal(err)
}
if err := st.AddWorkspaceMember(ctx, workspaces[0].ID, second.ID, "member"); err != nil {
t.Fatal(err)
}
dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspaces[0].ID, UserID: owner.ID, MemberIDs: []string{second.ID}})
if err != nil {
t.Fatal(err)
}
dmMessage, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: owner.ID, Body: "dm before"})
if err != nil {
t.Fatal(err)
}
updatedDM, dmEvent, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: dmMessage.ID, UserID: second.ID, Body: "dm after"})
if err != nil {
t.Fatal(err)
}
if updatedDM.DirectConversationID != dm.ID || dmEvent.ChannelID != "" {
t.Fatalf("unexpected dm update: %#v %#v", updatedDM, dmEvent)
}
if _, _, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: dmMessage.ID, UserID: second.ID}); err != nil {
t.Fatal(err)
}
events, err := st.ListEventsAfter(ctx, workspaces[0].ID, owner.ID, "", 20)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
for _, event := range events {
seen[event.Type] = true
}
for _, eventType := range []string{"channel.updated", "message.updated", "message.deleted"} {
if !seen[eventType] {
t.Fatalf("missing event %s in %#v", eventType, events)
}
}
}
func TestMutationsRejectInvalidInput(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
message, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "body"})
if err != nil {
t.Fatal(err)
}
if _, _, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: message.ID, UserID: owner.ID, Body: " "}); err == nil {
t.Fatal("expected empty message update error")
}
if _, _, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: "missing", UserID: owner.ID, Body: "x"}); err == nil {
t.Fatal("expected missing message update error")
}
if _, _, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: "missing", UserID: owner.ID}); err == nil {
t.Fatal("expected missing message delete error")
}
if _, _, err := st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: "missing", UserID: owner.ID}); err == nil {
t.Fatal("expected missing channel error")
}
outsider, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Outsider", Email: "outsider@example.com"})
if err != nil {
t.Fatal(err)
}
if _, _, err := st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: channels[0].ID, UserID: outsider.ID, Name: "nope"}); err == nil {
t.Fatal("expected outsider channel update error")
}
if _, _, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: message.ID, UserID: outsider.ID, Body: "nope"}); err == nil {
t.Fatal("expected outsider message update error")
}
if _, _, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: message.ID, UserID: outsider.ID}); err == nil {
t.Fatal("expected outsider message delete error")
}
}
func TestMutationsReturnOutboxErrors(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
channels, err := st.ListChannels(ctx, workspaces[0].ID, owner.ID)
if err != nil {
t.Fatal(err)
}
message, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channels[0].ID, AuthorID: owner.ID, Body: "body"})
if err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE events`); err != nil {
t.Fatal(err)
}
if _, _, err := st.UpdateChannel(ctx, store.UpdateChannelInput{ChannelID: channels[0].ID, UserID: owner.ID, Name: "after-events"}); err == nil {
t.Fatal("expected channel outbox error")
}
if _, _, err := st.UpdateMessage(ctx, store.UpdateMessageInput{MessageID: message.ID, UserID: owner.ID, Body: "after-events"}); err == nil {
t.Fatal("expected message update outbox error")
}
if _, _, err := st.DeleteMessage(ctx, store.DeleteMessageInput{MessageID: message.ID, UserID: owner.ID}); err == nil {
t.Fatal("expected message delete outbox error")
}
}

View File

@ -0,0 +1,75 @@
package sqlite
import (
"context"
"database/sql"
"strings"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func (s *Store) SearchMessages(ctx context.Context, workspaceID, userID, query string, limit int) ([]store.SearchResult, error) {
if limit <= 0 || limit > 100 {
limit = 50
}
if err := s.requireMembership(ctx, workspaceID, userID); err != nil {
return nil, err
}
query = strings.TrimSpace(query)
if query == "" {
return []store.SearchResult{}, nil
}
rows, err := s.db.QueryContext(ctx, `
SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq,
m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at,
u.id, u.display_name, u.avatar_url, u.created_at,
bm25(messages_fts) AS rank
FROM messages_fts
JOIN messages m ON m.id = messages_fts.message_id
JOIN users u ON u.id = m.author_id
WHERE messages_fts.workspace_id = ? AND messages_fts MATCH ?
ORDER BY rank
LIMIT ?`, workspaceID, query, limit)
if err != nil {
return nil, err
}
defer rows.Close()
out := []store.SearchResult{}
for rows.Next() {
msg, rank, err := scanSearchMessage(rows)
if err != nil {
return nil, err
}
out = append(out, store.SearchResult{Message: msg, Rank: rank})
}
return out, rows.Err()
}
func scanSearchMessage(row scanner) (store.Message, float64, error) {
var msg store.Message
var parent, edited, deleted sql.NullString
var channelSeq, threadSeq sql.NullInt64
var author store.User
var rank float64
err := row.Scan(&msg.ID, &msg.WorkspaceID, &msg.ChannelID, &msg.DirectConversationID, &msg.AuthorID, &parent, &msg.ThreadRootID, &channelSeq, &threadSeq, &msg.Body, &msg.BodyFormat, &msg.CreatedAt, &edited, &deleted, &author.ID, &author.DisplayName, &author.AvatarURL, &author.CreatedAt, &rank)
if err != nil {
return store.Message{}, 0, err
}
if parent.Valid {
msg.ParentMessageID = &parent.String
}
if channelSeq.Valid {
msg.ChannelSeq = &channelSeq.Int64
}
if threadSeq.Valid {
msg.ThreadSeq = &threadSeq.Int64
}
if edited.Valid {
msg.EditedAt = &edited.String
}
if deleted.Valid {
msg.DeletedAt = &deleted.String
}
msg.Author = &author
return msg, rank, nil
}

View File

@ -0,0 +1,490 @@
package sqlite
import (
"context"
"database/sql"
"embed"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/openclaw/clickclack/apps/api/internal/store"
_ "modernc.org/sqlite"
)
//go:embed migrations/*.sql
var migrationsFS embed.FS
type Store struct {
db *sql.DB
}
func Open(dbURL string) (*Store, error) {
path := strings.TrimPrefix(dbURL, "sqlite://")
if path == "" || path == dbURL {
path = dbURL
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return nil, err
}
db, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
for _, pragma := range []string{
"PRAGMA journal_mode=WAL",
"PRAGMA foreign_keys=ON",
"PRAGMA busy_timeout=5000",
} {
if _, err := db.ExecContext(ctx, pragma); err != nil {
_ = db.Close()
return nil, err
}
}
return &Store{db: db}, nil
}
func (s *Store) Close() error { return s.db.Close() }
func (s *Store) Migrate(ctx context.Context) error {
if _, err := s.db.ExecContext(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL)`); err != nil {
return err
}
entries, err := fs.ReadDir(migrationsFS, "migrations")
if err != nil {
return err
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
name := entry.Name()
var applied string
err := s.db.QueryRowContext(ctx, `SELECT name FROM schema_migrations WHERE name = ?`, name).Scan(&applied)
if err == nil {
continue
}
if !errors.Is(err, sql.ErrNoRows) {
return err
}
body, err := migrationsFS.ReadFile("migrations/" + name)
if err != nil {
return err
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
if _, err := tx.ExecContext(ctx, string(body)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("%s: %w", name, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)`, name, now()); err != nil {
_ = tx.Rollback()
return err
}
if err := tx.Commit(); err != nil {
return err
}
}
return nil
}
func (s *Store) EnsureBootstrap(ctx context.Context, name, email string) (store.User, error) {
user, err := s.FirstUser(ctx)
if err == nil {
return user, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return store.User{}, err
}
user, err = s.CreateUser(ctx, store.CreateUserInput{DisplayName: name, Email: email})
if err != nil {
return store.User{}, err
}
ws, err := s.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "ClickClack", Slug: "clickclack"}, user.ID)
if err != nil {
return store.User{}, err
}
_, _, err = s.CreateChannel(ctx, store.CreateChannelInput{WorkspaceID: ws.ID, Name: "general", Kind: "public", UserID: user.ID})
return user, err
}
func (s *Store) CreateUser(ctx context.Context, input store.CreateUserInput) (store.User, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.User{}, err
}
defer tx.Rollback()
user := store.User{
ID: newID("usr"),
DisplayName: strings.TrimSpace(input.DisplayName),
AvatarURL: "",
CreatedAt: now(),
}
if user.DisplayName == "" {
user.DisplayName = "Local User"
}
if _, err := tx.ExecContext(ctx, `INSERT INTO users (id, display_name, avatar_url, created_at) VALUES (?, ?, ?, ?)`, user.ID, user.DisplayName, user.AvatarURL, user.CreatedAt); err != nil {
return store.User{}, err
}
if input.Email != "" {
_, err = tx.ExecContext(ctx, `INSERT INTO identities (id, user_id, provider, provider_subject, email, created_at) VALUES (?, ?, 'local', ?, ?, ?)`, newID("idn"), user.ID, input.Email, input.Email, user.CreatedAt)
if err != nil {
return store.User{}, err
}
}
return user, tx.Commit()
}
func (s *Store) FirstUser(ctx context.Context) (store.User, error) {
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, display_name, avatar_url, created_at FROM users ORDER BY created_at LIMIT 1`))
}
func (s *Store) GetUser(ctx context.Context, id string) (store.User, error) {
return scanUser(s.db.QueryRowContext(ctx, `SELECT id, display_name, avatar_url, created_at FROM users WHERE id = ?`, id))
}
func (s *Store) ListWorkspaces(ctx context.Context, userID string) ([]store.Workspace, error) {
rows, err := s.db.QueryContext(ctx, `
SELECT w.id, w.name, w.slug, w.created_at
FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE wm.user_id = ?
ORDER BY w.created_at`, userID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []store.Workspace{}
for rows.Next() {
var w store.Workspace
if err := rows.Scan(&w.ID, &w.Name, &w.Slug, &w.CreatedAt); err != nil {
return nil, err
}
out = append(out, w)
}
return out, rows.Err()
}
func (s *Store) CreateWorkspace(ctx context.Context, input store.CreateWorkspaceInput, ownerID string) (store.Workspace, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Workspace{}, err
}
defer tx.Rollback()
w := store.Workspace{ID: newID("wsp"), Name: strings.TrimSpace(input.Name), Slug: slug(input.Slug), CreatedAt: now()}
if w.Name == "" {
w.Name = "Untitled"
}
if w.Slug == "" {
w.Slug = slug(w.Name)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO workspaces (id, name, slug, created_at) VALUES (?, ?, ?, ?)`, w.ID, w.Name, w.Slug, w.CreatedAt); err != nil {
return store.Workspace{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO workspace_members (workspace_id, user_id, role, created_at) VALUES (?, ?, 'owner', ?)`, w.ID, ownerID, w.CreatedAt); err != nil {
return store.Workspace{}, err
}
return w, tx.Commit()
}
func (s *Store) GetWorkspace(ctx context.Context, workspaceID, userID string) (store.Workspace, error) {
return scanWorkspace(s.db.QueryRowContext(ctx, `
SELECT w.id, w.name, w.slug, w.created_at
FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
WHERE w.id = ? AND wm.user_id = ?`, workspaceID, userID))
}
func (s *Store) ListChannels(ctx context.Context, workspaceID, userID string) ([]store.Channel, error) {
if err := s.requireMembership(ctx, workspaceID, userID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `SELECT id, workspace_id, name, kind, created_at, archived_at FROM channels WHERE workspace_id = ? ORDER BY name`, workspaceID)
if err != nil {
return nil, err
}
defer rows.Close()
out := []store.Channel{}
for rows.Next() {
ch, err := scanChannel(rows)
if err != nil {
return nil, err
}
out = append(out, ch)
}
return out, rows.Err()
}
func (s *Store) CreateChannel(ctx context.Context, input store.CreateChannelInput) (store.Channel, store.Event, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Channel{}, store.Event{}, err
}
defer tx.Rollback()
if err := requireMembershipTx(ctx, tx, input.WorkspaceID, input.UserID); err != nil {
return store.Channel{}, store.Event{}, err
}
ch := store.Channel{ID: newID("chn"), WorkspaceID: input.WorkspaceID, Name: slug(input.Name), Kind: input.Kind, CreatedAt: now()}
if ch.Name == "" {
ch.Name = "general"
}
if ch.Kind == "" {
ch.Kind = "public"
}
if _, err := tx.ExecContext(ctx, `INSERT INTO channels (id, workspace_id, name, kind, created_at) VALUES (?, ?, ?, ?, ?)`, ch.ID, ch.WorkspaceID, ch.Name, ch.Kind, ch.CreatedAt); err != nil {
return store.Channel{}, store.Event{}, err
}
event, err := insertEvent(ctx, tx, ch.WorkspaceID, ch.ID, "channel.created", nil, map[string]string{"channel_id": ch.ID})
if err != nil {
return store.Channel{}, store.Event{}, err
}
return ch, event, tx.Commit()
}
func (s *Store) ListMessages(ctx context.Context, channelID, userID string, afterSeq int64, limit int) ([]store.Message, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
var workspaceID string
if err := s.db.QueryRowContext(ctx, `SELECT workspace_id FROM channels WHERE id = ?`, channelID).Scan(&workspaceID); err != nil {
return nil, err
}
if err := s.requireMembership(ctx, workspaceID, userID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq,
m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at,
u.id, u.display_name, u.avatar_url, u.created_at
FROM messages m
JOIN users u ON u.id = m.author_id
WHERE m.channel_id = ? AND m.parent_message_id IS NULL AND m.channel_seq > ?
ORDER BY m.channel_seq
LIMIT ?`, channelID, afterSeq, limit)
if err != nil {
return nil, err
}
defer rows.Close()
messages, err := scanMessages(rows)
if err != nil {
return nil, err
}
return s.hydrateAttachments(ctx, messages)
}
func (s *Store) CreateMessage(ctx context.Context, input store.CreateMessageInput) (store.Message, store.Event, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Message{}, store.Event{}, err
}
defer tx.Rollback()
var workspaceID string
if err := tx.QueryRowContext(ctx, `SELECT workspace_id FROM channels WHERE id = ?`, input.ChannelID).Scan(&workspaceID); err != nil {
return store.Message{}, store.Event{}, err
}
if err := requireMembershipTx(ctx, tx, workspaceID, input.AuthorID); err != nil {
return store.Message{}, store.Event{}, err
}
var seq int64
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(channel_seq), 0) + 1 FROM messages WHERE channel_id = ? AND parent_message_id IS NULL`, input.ChannelID).Scan(&seq); err != nil {
return store.Message{}, store.Event{}, err
}
id := newID("msg")
createdAt := now()
body := strings.TrimSpace(input.Body)
if body == "" {
return store.Message{}, store.Event{}, errors.New("message body is required")
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at)
VALUES (?, ?, ?, NULL, ?, NULL, ?, ?, NULL, ?, 'markdown', ?)`, id, workspaceID, input.ChannelID, input.AuthorID, id, seq, body, createdAt); err != nil {
return store.Message{}, store.Event{}, err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO thread_state (root_message_id) VALUES (?)`, id); err != nil {
return store.Message{}, store.Event{}, err
}
event, err := insertEvent(ctx, tx, workspaceID, input.ChannelID, "message.created", &seq, map[string]string{"message_id": id})
if err != nil {
return store.Message{}, store.Event{}, err
}
msg, err := getMessageTx(ctx, tx, id)
if err != nil {
return store.Message{}, store.Event{}, err
}
return msg, event, tx.Commit()
}
func (s *Store) GetThread(ctx context.Context, rootMessageID, userID string, limit int) (store.Message, []store.Message, store.ThreadState, error) {
if limit <= 0 || limit > 200 {
limit = 100
}
root, err := getMessage(ctx, s.db, rootMessageID)
if err != nil {
return store.Message{}, nil, store.ThreadState{}, err
}
if root.ParentMessageID != nil {
return store.Message{}, nil, store.ThreadState{}, errors.New("thread root must be a root message")
}
if err := s.requireMembership(ctx, root.WorkspaceID, userID); err != nil {
return store.Message{}, nil, store.ThreadState{}, err
}
roots, err := s.hydrateAttachments(ctx, []store.Message{root})
if err != nil {
return store.Message{}, nil, store.ThreadState{}, err
}
root = roots[0]
rows, err := s.db.QueryContext(ctx, `
SELECT m.id, m.workspace_id, COALESCE(m.channel_id, ''), COALESCE(m.direct_conversation_id, ''), m.author_id, m.parent_message_id, m.thread_root_id, m.channel_seq, m.thread_seq,
m.body, m.body_format, m.created_at, m.edited_at, m.deleted_at,
u.id, u.display_name, u.avatar_url, u.created_at
FROM messages m
JOIN users u ON u.id = m.author_id
WHERE m.thread_root_id = ? AND m.parent_message_id = ?
ORDER BY m.thread_seq
LIMIT ?`, rootMessageID, rootMessageID, limit)
if err != nil {
return store.Message{}, nil, store.ThreadState{}, err
}
defer rows.Close()
replies, err := scanMessages(rows)
if err != nil {
return store.Message{}, nil, store.ThreadState{}, err
}
replies, err = s.hydrateAttachments(ctx, replies)
if err != nil {
return store.Message{}, nil, store.ThreadState{}, err
}
state, err := getThreadState(ctx, s.db, rootMessageID)
return root, replies, state, err
}
func (s *Store) CreateThreadReply(ctx context.Context, input store.CreateThreadReplyInput) (store.Message, store.ThreadState, []store.Event, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Message{}, store.ThreadState{}, nil, err
}
defer tx.Rollback()
root, err := getMessageTx(ctx, tx, input.RootMessageID)
if err != nil {
return store.Message{}, store.ThreadState{}, nil, err
}
if root.ParentMessageID != nil {
return store.Message{}, store.ThreadState{}, nil, errors.New("nested thread replies are not supported")
}
if err := requireMembershipTx(ctx, tx, root.WorkspaceID, input.AuthorID); err != nil {
return store.Message{}, store.ThreadState{}, nil, err
}
var seq int64
if err := tx.QueryRowContext(ctx, `SELECT COALESCE(MAX(thread_seq), 0) + 1 FROM messages WHERE thread_root_id = ? AND parent_message_id = ?`, root.ID, root.ID).Scan(&seq); err != nil {
return store.Message{}, store.ThreadState{}, nil, err
}
id := newID("msg")
createdAt := now()
body := strings.TrimSpace(input.Body)
if body == "" {
return store.Message{}, store.ThreadState{}, nil, errors.New("reply body is required")
}
if _, err := tx.ExecContext(ctx, `
INSERT INTO messages (id, workspace_id, channel_id, direct_conversation_id, author_id, parent_message_id, thread_root_id, channel_seq, thread_seq, body, body_format, created_at)
VALUES (?, ?, ?, NULL, ?, ?, ?, NULL, ?, ?, 'markdown', ?)`, id, root.WorkspaceID, root.ChannelID, input.AuthorID, root.ID, root.ID, seq, body, createdAt); err != nil {
return store.Message{}, store.ThreadState{}, nil, err
}
state, err := updateThreadState(ctx, tx, root.ID, input.AuthorID, createdAt)
if err != nil {
return store.Message{}, store.ThreadState{}, nil, err
}
replyEvent, err := insertEvent(ctx, tx, root.WorkspaceID, root.ChannelID, "thread.reply_created", nil, map[string]string{"message_id": id, "root_message_id": root.ID})
if err != nil {
return store.Message{}, store.ThreadState{}, nil, err
}
stateEvent, err := insertEvent(ctx, tx, root.WorkspaceID, root.ChannelID, "thread.state_updated", nil, map[string]string{"root_message_id": root.ID})
if err != nil {
return store.Message{}, store.ThreadState{}, nil, err
}
msg, err := getMessageTx(ctx, tx, id)
if err != nil {
return store.Message{}, store.ThreadState{}, nil, err
}
return msg, state, []store.Event{replyEvent, stateEvent}, tx.Commit()
}
func (s *Store) AddReaction(ctx context.Context, input store.CreateReactionInput) (store.Event, error) {
return s.reaction(ctx, input, true)
}
func (s *Store) RemoveReaction(ctx context.Context, input store.CreateReactionInput) (store.Event, error) {
return s.reaction(ctx, input, false)
}
func (s *Store) ListEventsAfter(ctx context.Context, workspaceID, userID, cursor string, limit int) ([]store.Event, error) {
if limit <= 0 || limit > 500 {
limit = 200
}
if err := s.requireMembership(ctx, workspaceID, userID); err != nil {
return nil, err
}
rows, err := s.db.QueryContext(ctx, `
SELECT id, cursor, workspace_id, COALESCE(channel_id, ''), type, seq, payload_json, created_at
FROM events
WHERE workspace_id = ? AND cursor > ?
ORDER BY cursor
LIMIT ?`, workspaceID, cursor, limit)
if err != nil {
return nil, err
}
defer rows.Close()
return scanEvents(rows)
}
func (s *Store) reaction(ctx context.Context, input store.CreateReactionInput, add bool) (store.Event, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return store.Event{}, err
}
defer tx.Rollback()
msg, err := getMessageTx(ctx, tx, input.MessageID)
if err != nil {
return store.Event{}, err
}
if err := requireMembershipTx(ctx, tx, msg.WorkspaceID, input.UserID); err != nil {
return store.Event{}, err
}
if add {
_, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO reactions (message_id, user_id, emoji, created_at) VALUES (?, ?, ?, ?)`, input.MessageID, input.UserID, input.Emoji, now())
} else {
_, err = tx.ExecContext(ctx, `DELETE FROM reactions WHERE message_id = ? AND user_id = ? AND emoji = ?`, input.MessageID, input.UserID, input.Emoji)
}
if err != nil {
return store.Event{}, err
}
eventType := "reaction.added"
if !add {
eventType = "reaction.removed"
}
event, err := insertEvent(ctx, tx, msg.WorkspaceID, msg.ChannelID, eventType, msg.ChannelSeq, map[string]string{"message_id": input.MessageID, "emoji": input.Emoji})
if err != nil {
return store.Event{}, err
}
return event, tx.Commit()
}
func (s *Store) requireMembership(ctx context.Context, workspaceID, userID string) error {
var one int
err := s.db.QueryRowContext(ctx, `SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?`, workspaceID, userID).Scan(&one)
return err
}
func requireMembershipTx(ctx context.Context, tx *sql.Tx, workspaceID, userID string) error {
var one int
err := tx.QueryRowContext(ctx, `SELECT 1 FROM workspace_members WHERE workspace_id = ? AND user_id = ?`, workspaceID, userID).Scan(&one)
return err
}

View File

@ -0,0 +1,232 @@
package sqlite
import (
"bytes"
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func TestStoreValidationAndAdminHelpers(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
owner, err := st.EnsureBootstrap(ctx, "Owner", "owner@example.com")
if err != nil {
t.Fatal(err)
}
workspaces, err := st.ListWorkspaces(ctx, owner.ID)
if err != nil {
t.Fatal(err)
}
workspace := workspaces[0]
channels, err := st.ListChannels(ctx, workspace.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
channel := channels[0]
if _, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "ClickClack", Slug: workspace.Slug}, owner.ID); err == nil {
t.Fatal("expected duplicate workspace slug error")
}
if _, _, err := st.CreateMessage(ctx, store.CreateMessageInput{ChannelID: channel.ID, AuthorID: owner.ID}); err == nil {
t.Fatal("expected empty message error")
}
if _, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{RootMessageID: channel.ID, AuthorID: owner.ID, Body: "nope"}); err == nil {
t.Fatal("expected missing root message error")
}
if results, err := st.SearchMessages(ctx, workspace.ID, owner.ID, "", 10); err != nil || len(results) != 0 {
t.Fatalf("expected empty search results, got %#v err=%v", results, err)
}
if _, err := st.CreateInvite(ctx, workspace.ID, owner.ID); err != nil {
t.Fatal(err)
}
link, err := st.CreateMagicLink(ctx, "magic@example.com", "Magic User")
if err != nil {
t.Fatal(err)
}
magicUser, session, err := st.ConsumeMagicLink(ctx, link.Token)
if err != nil {
t.Fatal(err)
}
if magicUser.DisplayName != "Magic User" || session.Token == "" {
t.Fatalf("unexpected magic auth result: %#v %#v", magicUser, session)
}
sessionUser, err := st.GetSessionUser(ctx, session.Token)
if err != nil {
t.Fatal(err)
}
if sessionUser.ID != magicUser.ID {
t.Fatalf("expected session user %s, got %s", magicUser.ID, sessionUser.ID)
}
if _, _, err := st.ConsumeMagicLink(ctx, link.Token); err == nil {
t.Fatal("expected consumed magic link error")
}
if _, err := st.CreateMagicLink(ctx, "", "No Email"); err == nil {
t.Fatal("expected missing email error")
}
var exported bytes.Buffer
if err := st.ExportJSON(ctx, &exported); err != nil {
t.Fatal(err)
}
var exportBody map[string][]map[string]any
if err := json.Unmarshal(exported.Bytes(), &exportBody); err != nil {
t.Fatal(err)
}
if len(exportBody["auth_magic_links"]) == 0 || len(exportBody["sessions"]) == 0 {
t.Fatalf("expected auth tables in export, got keys %#v", exportBody)
}
if err := st.Backup(ctx, filepath.Join(t.TempDir(), "backup.db")); err != nil {
t.Fatal(err)
}
if _, err := st.db.ExecContext(ctx, `DROP TABLE sessions`); err != nil {
t.Fatal(err)
}
if err := st.ExportJSON(ctx, &bytes.Buffer{}); err == nil {
t.Fatal("expected export failure")
}
second, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "Second", Email: "second@example.com"})
if err != nil {
t.Fatal(err)
}
if _, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{second.ID}}); err == nil {
t.Fatal("expected dm membership error for second user")
}
if err := st.AddWorkspaceMember(ctx, workspace.ID, second.ID, "member"); err != nil {
t.Fatal(err)
}
dm, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{WorkspaceID: workspace.ID, UserID: owner.ID, MemberIDs: []string{second.ID}})
if err != nil {
t.Fatal(err)
}
dms, err := st.ListDirectConversations(ctx, workspace.ID, owner.ID)
if err != nil {
t.Fatal(err)
}
if len(dms) != 1 || dms[0].ID != dm.ID {
t.Fatalf("unexpected dm list: %#v", dms)
}
if _, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{ConversationID: dm.ID, AuthorID: second.ID}); err == nil {
t.Fatal("expected empty dm message error")
}
}
func TestOpenRejectsBadDirectory(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "not-a-dir")
if err := os.WriteFile(path, []byte("file"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Open("sqlite://" + filepath.Join(path, "db.sqlite")); err == nil {
t.Fatal("expected bad directory error")
}
}
func TestStoreClosedDatabaseErrors(t *testing.T) {
t.Parallel()
ctx := context.Background()
st := newTestStore(t)
if err := st.db.Close(); err != nil {
t.Fatal(err)
}
errorCases := []struct {
name string
fn func() error
}{
{"migrate", func() error { return st.Migrate(ctx) }},
{"create user", func() error {
_, err := st.CreateUser(ctx, store.CreateUserInput{DisplayName: "x"})
return err
}},
{"first user", func() error {
_, err := st.FirstUser(ctx)
return err
}},
{"get user", func() error {
_, err := st.GetUser(ctx, "usr_missing")
return err
}},
{"list workspaces", func() error {
_, err := st.ListWorkspaces(ctx, "usr_missing")
return err
}},
{"create workspace", func() error {
_, err := st.CreateWorkspace(ctx, store.CreateWorkspaceInput{Name: "x"}, "usr_missing")
return err
}},
{"create channel", func() error {
_, _, err := st.CreateChannel(ctx, store.CreateChannelInput{})
return err
}},
{"create message", func() error {
_, _, err := st.CreateMessage(ctx, store.CreateMessageInput{})
return err
}},
{"create reply", func() error {
_, _, _, err := st.CreateThreadReply(ctx, store.CreateThreadReplyInput{})
return err
}},
{"add reaction", func() error {
_, err := st.AddReaction(ctx, store.CreateReactionInput{})
return err
}},
{"remove reaction", func() error {
_, err := st.RemoveReaction(ctx, store.CreateReactionInput{})
return err
}},
{"create upload", func() error {
_, err := st.CreateUpload(ctx, store.CreateUploadInput{})
return err
}},
{"attach upload", func() error {
return st.AttachUpload(ctx, store.AttachUploadInput{})
}},
{"create dm", func() error {
_, err := st.CreateDirectConversation(ctx, store.CreateDirectConversationInput{})
return err
}},
{"create dm message", func() error {
_, _, err := st.CreateDirectMessage(ctx, store.CreateDirectMessageInput{})
return err
}},
{"magic link", func() error {
_, err := st.CreateMagicLink(ctx, "x@example.com", "x")
return err
}},
{"identity", func() error {
_, err := st.UpsertIdentityUser(ctx, store.UpsertIdentityUserInput{Provider: "github", ProviderSubject: "1"})
return err
}},
{"session", func() error {
_, err := st.CreateSession(ctx, "usr_missing")
return err
}},
}
for _, tc := range errorCases {
t.Run(tc.name, func(t *testing.T) {
if err := tc.fn(); err == nil {
t.Fatal("expected closed database error")
}
})
}
}
func newTestStore(t *testing.T) *Store {
t.Helper()
st, err := Open("sqlite://" + filepath.Join(t.TempDir(), "clickclack.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(context.Background()); err != nil {
t.Fatal(err)
}
return st
}

View File

@ -0,0 +1,106 @@
package sqlite
import (
"context"
"errors"
"github.com/openclaw/clickclack/apps/api/internal/store"
)
func (s *Store) CreateUpload(ctx context.Context, input store.CreateUploadInput) (store.Upload, error) {
if err := s.requireMembership(ctx, input.WorkspaceID, input.OwnerID); err != nil {
return store.Upload{}, err
}
upload := store.Upload{
ID: newID("upl"),
WorkspaceID: input.WorkspaceID,
OwnerID: input.OwnerID,
Filename: input.Filename,
ContentType: input.ContentType,
ByteSize: input.ByteSize,
StoragePath: input.StoragePath,
CreatedAt: now(),
}
_, err := s.db.ExecContext(ctx, `
INSERT INTO uploads (id, workspace_id, owner_id, filename, content_type, byte_size, storage_path, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
upload.ID, upload.WorkspaceID, upload.OwnerID, upload.Filename, upload.ContentType, upload.ByteSize, upload.StoragePath, upload.CreatedAt)
return upload, err
}
func (s *Store) GetUpload(ctx context.Context, uploadID, userID string) (store.Upload, error) {
upload, err := scanUpload(s.db.QueryRowContext(ctx, `
SELECT id, workspace_id, owner_id, filename, content_type, byte_size, storage_path, created_at
FROM uploads
WHERE id = ?`, uploadID))
if err != nil {
return store.Upload{}, err
}
if err := s.requireMembership(ctx, upload.WorkspaceID, userID); err != nil {
return store.Upload{}, err
}
return upload, nil
}
func (s *Store) AttachUpload(ctx context.Context, input store.AttachUploadInput) error {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
msg, err := getMessageTx(ctx, tx, input.MessageID)
if err != nil {
return err
}
if err := requireMembershipTx(ctx, tx, msg.WorkspaceID, input.UserID); err != nil {
return err
}
var uploadWorkspace string
if err := tx.QueryRowContext(ctx, `SELECT workspace_id FROM uploads WHERE id = ?`, input.UploadID).Scan(&uploadWorkspace); err != nil {
return err
}
if uploadWorkspace != msg.WorkspaceID {
return errors.New("upload and message workspaces differ")
}
_, err = tx.ExecContext(ctx, `INSERT OR IGNORE INTO message_attachments (message_id, upload_id, created_at) VALUES (?, ?, ?)`, input.MessageID, input.UploadID, now())
if err != nil {
return err
}
return tx.Commit()
}
func scanUpload(row scanner) (store.Upload, error) {
var upload store.Upload
err := row.Scan(&upload.ID, &upload.WorkspaceID, &upload.OwnerID, &upload.Filename, &upload.ContentType, &upload.ByteSize, &upload.StoragePath, &upload.CreatedAt)
return upload, err
}
func (s *Store) hydrateAttachments(ctx context.Context, messages []store.Message) ([]store.Message, error) {
for i := range messages {
rows, err := s.db.QueryContext(ctx, `
SELECT u.id, u.workspace_id, u.owner_id, u.filename, u.content_type, u.byte_size, u.storage_path, u.created_at
FROM uploads u
JOIN message_attachments ma ON ma.upload_id = u.id
WHERE ma.message_id = ?
ORDER BY ma.created_at`, messages[i].ID)
if err != nil {
return nil, err
}
uploads := []store.Upload{}
for rows.Next() {
upload, err := scanUpload(rows)
if err != nil {
_ = rows.Close()
return nil, err
}
uploads = append(uploads, upload)
}
if err := rows.Close(); err != nil {
return nil, err
}
if len(uploads) > 0 {
messages[i].Attachments = uploads
}
}
return messages, nil
}

View File

@ -0,0 +1,243 @@
package store
import "context"
type User struct {
ID string `json:"id"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
CreatedAt string `json:"created_at"`
}
type Workspace struct {
ID string `json:"id"`
Name string `json:"name"`
Slug string `json:"slug"`
CreatedAt string `json:"created_at"`
}
type Channel struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Name string `json:"name"`
Kind string `json:"kind"`
CreatedAt string `json:"created_at"`
ArchivedAt *string `json:"archived_at,omitempty"`
}
type Message struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
ChannelID string `json:"channel_id,omitempty"`
DirectConversationID string `json:"direct_conversation_id,omitempty"`
AuthorID string `json:"author_id"`
ParentMessageID *string `json:"parent_message_id,omitempty"`
ThreadRootID string `json:"thread_root_id"`
ChannelSeq *int64 `json:"channel_seq,omitempty"`
ThreadSeq *int64 `json:"thread_seq,omitempty"`
Body string `json:"body"`
BodyFormat string `json:"body_format"`
CreatedAt string `json:"created_at"`
EditedAt *string `json:"edited_at,omitempty"`
DeletedAt *string `json:"deleted_at,omitempty"`
Author *User `json:"author,omitempty"`
Attachments []Upload `json:"attachments,omitempty"`
}
type ThreadState struct {
RootMessageID string `json:"root_message_id"`
ReplyCount int64 `json:"reply_count"`
LastReplyAt *string `json:"last_reply_at,omitempty"`
LastReplyAuthorIDs []string `json:"last_reply_author_ids"`
LastReplyAuthorIDsJSON string `json:"-"`
}
type Event struct {
ID string `json:"id"`
Cursor string `json:"cursor"`
Type string `json:"type"`
WorkspaceID string `json:"workspace_id"`
ChannelID string `json:"channel_id,omitempty"`
Seq *int64 `json:"seq,omitempty"`
CreatedAt string `json:"created_at"`
PayloadJSON string `json:"-"`
Payload any `json:"payload"`
}
type CreateUserInput struct {
DisplayName string
Email string
}
type UpsertIdentityUserInput struct {
Provider string
ProviderSubject string
Email string
DisplayName string
AvatarURL string
}
type CreateWorkspaceInput struct {
Name string
Slug string
}
type CreateChannelInput struct {
WorkspaceID string
Name string
Kind string
UserID string
}
type UpdateChannelInput struct {
ChannelID string
UserID string
Name string
Kind string
Archived *bool
}
type CreateMessageInput struct {
ChannelID string
AuthorID string
Body string
}
type UpdateMessageInput struct {
MessageID string
UserID string
Body string
}
type DeleteMessageInput struct {
MessageID string
UserID string
}
type CreateThreadReplyInput struct {
RootMessageID string
AuthorID string
Body string
}
type CreateReactionInput struct {
MessageID string
UserID string
Emoji string
}
type Upload struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
OwnerID string `json:"owner_id"`
Filename string `json:"filename"`
ContentType string `json:"content_type"`
ByteSize int64 `json:"byte_size"`
StoragePath string `json:"storage_path,omitempty"`
CreatedAt string `json:"created_at"`
}
type CreateUploadInput struct {
WorkspaceID string
OwnerID string
Filename string
ContentType string
ByteSize int64
StoragePath string
}
type AttachUploadInput struct {
MessageID string
UploadID string
UserID string
}
type SearchResult struct {
Message Message `json:"message"`
Rank float64 `json:"rank"`
}
type DirectConversation struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
CreatedAt string `json:"created_at"`
Members []User `json:"members"`
}
type CreateDirectConversationInput struct {
WorkspaceID string
UserID string
MemberIDs []string
}
type CreateDirectMessageInput struct {
ConversationID string
AuthorID string
Body string
}
type Invite struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Token string `json:"token"`
CreatedBy string `json:"created_by"`
CreatedAt string `json:"created_at"`
AcceptedAt *string `json:"accepted_at,omitempty"`
}
type MagicLink struct {
ID string `json:"id"`
Token string `json:"token"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
UsedAt *string `json:"used_at,omitempty"`
}
type Session struct {
ID string `json:"id"`
Token string `json:"token"`
UserID string `json:"user_id"`
CreatedAt string `json:"created_at"`
ExpiresAt string `json:"expires_at"`
}
type Store interface {
Close() error
Migrate(ctx context.Context) error
EnsureBootstrap(ctx context.Context, name, email string) (User, error)
CreateUser(ctx context.Context, input CreateUserInput) (User, error)
UpsertIdentityUser(ctx context.Context, input UpsertIdentityUserInput) (User, error)
AddWorkspaceMember(ctx context.Context, workspaceID, userID, role string) error
FirstUser(ctx context.Context) (User, error)
GetUser(ctx context.Context, id string) (User, error)
ListWorkspaces(ctx context.Context, userID string) ([]Workspace, error)
CreateWorkspace(ctx context.Context, input CreateWorkspaceInput, ownerID string) (Workspace, error)
GetWorkspace(ctx context.Context, workspaceID, userID string) (Workspace, error)
ListChannels(ctx context.Context, workspaceID, userID string) ([]Channel, error)
CreateChannel(ctx context.Context, input CreateChannelInput) (Channel, Event, error)
UpdateChannel(ctx context.Context, input UpdateChannelInput) (Channel, Event, error)
ListMessages(ctx context.Context, channelID, userID string, afterSeq int64, limit int) ([]Message, error)
CreateMessage(ctx context.Context, input CreateMessageInput) (Message, Event, error)
UpdateMessage(ctx context.Context, input UpdateMessageInput) (Message, Event, error)
DeleteMessage(ctx context.Context, input DeleteMessageInput) (Message, Event, error)
GetThread(ctx context.Context, rootMessageID, userID string, limit int) (Message, []Message, ThreadState, error)
CreateThreadReply(ctx context.Context, input CreateThreadReplyInput) (Message, ThreadState, []Event, error)
AddReaction(ctx context.Context, input CreateReactionInput) (Event, error)
RemoveReaction(ctx context.Context, input CreateReactionInput) (Event, error)
ListEventsAfter(ctx context.Context, workspaceID, userID, cursor string, limit int) ([]Event, error)
CreateUpload(ctx context.Context, input CreateUploadInput) (Upload, error)
GetUpload(ctx context.Context, uploadID, userID string) (Upload, error)
AttachUpload(ctx context.Context, input AttachUploadInput) error
SearchMessages(ctx context.Context, workspaceID, userID, query string, limit int) ([]SearchResult, error)
ListDirectConversations(ctx context.Context, workspaceID, userID string) ([]DirectConversation, error)
CreateDirectConversation(ctx context.Context, input CreateDirectConversationInput) (DirectConversation, error)
ListDirectMessages(ctx context.Context, conversationID, userID string, afterSeq int64, limit int) ([]Message, error)
CreateDirectMessage(ctx context.Context, input CreateDirectMessageInput) (Message, Event, error)
CreateInvite(ctx context.Context, workspaceID, createdBy string) (Invite, error)
CreateMagicLink(ctx context.Context, email, displayName string) (MagicLink, error)
ConsumeMagicLink(ctx context.Context, token string) (User, Session, error)
CreateSession(ctx context.Context, userID string) (Session, error)
GetSessionUser(ctx context.Context, token string) (User, error)
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClickClack</title>
<script type="module" crossorigin src="/assets/index-4rv34_La.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Du33dVG9.css">
</head>
<body>
<div id="app"></div>
</body>
</html>

View File

@ -0,0 +1,8 @@
package webassets
import "embed"
// Dist is replaced by the root pnpm build script after the Svelte app builds.
//
//go:embed dist/*
var Dist embed.FS

12
apps/web/index.html Normal file
View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ClickClack</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

23
apps/web/package.json Normal file
View File

@ -0,0 +1,23 @@
{
"name": "@clickclack/web",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1",
"build": "vite build",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"preview": "vite preview --host 127.0.0.1"
},
"dependencies": {
"@sveltejs/vite-plugin-svelte": "^6.2.1",
"dompurify": "^3.3.0",
"marked": "^17.0.1",
"svelte": "^5.45.6",
"vite": "^7.2.4"
},
"devDependencies": {
"@typescript/native-preview": "7.0.0-dev.20260507.1"
}
}

472
apps/web/src/App.svelte Normal file
View File

@ -0,0 +1,472 @@
<script lang="ts">
import { onDestroy, onMount } from "svelte";
import { api } from "./lib/api";
import { markdown, time } from "./lib/format";
import type { Channel, DirectConversation, Message, RealtimeEvent, SearchResult, ThreadState, Upload, User, Workspace } from "./lib/types";
let user: User | null = null;
let workspaces: Workspace[] = [];
let channels: Channel[] = [];
let directConversations: DirectConversation[] = [];
let messages: Message[] = [];
let replies: Message[] = [];
let selectedWorkspaceID = "";
let selectedChannelID = "";
let selectedDirectID = "";
let selectedThread: Message | null = null;
let selectedThreadState: ThreadState | null = null;
let messageBody = "";
let replyBody = "";
let workspaceName = "";
let channelName = "";
let directMemberID = "";
let searchQuery = "";
let searchResults: SearchResult[] = [];
let pendingUpload: Upload | null = null;
let status = "loading";
let socket: WebSocket | null = null;
let reconnectTimer: number | undefined;
$: selectedWorkspace = workspaces.find((workspace) => workspace.id === selectedWorkspaceID);
$: selectedChannel = channels.find((channel) => channel.id === selectedChannelID);
$: selectedDirect = directConversations.find((conversation) => conversation.id === selectedDirectID);
onMount(() => {
void boot();
});
onDestroy(() => {
socket?.close();
if (reconnectTimer) window.clearTimeout(reconnectTimer);
});
async function boot() {
try {
const me = await api<{ user: User }>("/api/me");
user = me.user;
await loadWorkspaces();
status = "ready";
} catch (error) {
status = error instanceof Error ? error.message : "Could not load ClickClack";
}
}
async function loadWorkspaces() {
const data = await api<{ workspaces: Workspace[] }>("/api/workspaces");
workspaces = data.workspaces;
selectedWorkspaceID = selectedWorkspaceID || workspaces[0]?.id || "";
await loadChannels();
await loadDirectConversations();
connectRealtime();
}
async function createWorkspace() {
if (!workspaceName.trim()) return;
const data = await api<{ workspace: Workspace }>("/api/workspaces", {
method: "POST",
body: JSON.stringify({ name: workspaceName })
});
workspaceName = "";
workspaces = [...workspaces, data.workspace];
selectedWorkspaceID = data.workspace.id;
await loadChannels();
await loadDirectConversations();
connectRealtime();
}
async function loadChannels() {
if (!selectedWorkspaceID) return;
const data = await api<{ channels: Channel[] }>(`/api/workspaces/${selectedWorkspaceID}/channels`);
channels = data.channels;
selectedChannelID = channels.find((channel) => channel.id === selectedChannelID)?.id || channels[0]?.id || "";
selectedThread = null;
replies = [];
await loadMessages();
}
async function createChannel() {
if (!selectedWorkspaceID || !channelName.trim()) return;
const data = await api<{ channel: Channel }>(`/api/workspaces/${selectedWorkspaceID}/channels`, {
method: "POST",
body: JSON.stringify({ name: channelName, kind: "public" })
});
channelName = "";
channels = [...channels, data.channel];
selectedChannelID = data.channel.id;
await loadMessages();
}
async function loadMessages() {
if (selectedDirectID) {
const data = await api<{ messages: Message[] }>(`/api/dms/${selectedDirectID}/messages`);
messages = data.messages;
return;
}
if (!selectedChannelID) {
messages = [];
return;
}
const data = await api<{ messages: Message[] }>(`/api/channels/${selectedChannelID}/messages`);
messages = data.messages;
}
async function sendMessage() {
const body = messageBody.trim();
if (!body || (!selectedChannelID && !selectedDirectID)) return;
messageBody = "";
const path = selectedDirectID ? `/api/dms/${selectedDirectID}/messages` : `/api/channels/${selectedChannelID}/messages`;
const data = await api<{ message: Message }>(path, {
method: "POST",
body: JSON.stringify({ body })
});
if (pendingUpload) {
await api(`/api/messages/${data.message.id}/attachments`, {
method: "POST",
body: JSON.stringify({ upload_id: pendingUpload.id })
});
pendingUpload = null;
}
if (!messages.some((message) => message.id === data.message.id)) {
messages = [...messages, data.message];
}
}
async function openThread(message: Message) {
selectedThread = message;
const data = await api<{ root: Message; replies: Message[]; thread_state: ThreadState }>(`/api/messages/${message.id}/thread`);
selectedThread = data.root;
replies = data.replies;
selectedThreadState = data.thread_state;
}
async function sendReply() {
const body = replyBody.trim();
if (!body || !selectedThread) return;
replyBody = "";
const data = await api<{ message: Message; thread_state: ThreadState }>(`/api/messages/${selectedThread.id}/thread/replies`, {
method: "POST",
body: JSON.stringify({ body })
});
if (!replies.some((reply) => reply.id === data.message.id)) {
replies = [...replies, data.message];
}
selectedThreadState = data.thread_state;
}
async function searchMessages() {
if (!selectedWorkspaceID || !searchQuery.trim()) {
searchResults = [];
return;
}
const data = await api<{ results: SearchResult[] }>(
`/api/search?workspace_id=${encodeURIComponent(selectedWorkspaceID)}&q=${encodeURIComponent(searchQuery.trim())}`
);
searchResults = data.results;
}
async function uploadFile(event: Event) {
const input = event.currentTarget as HTMLInputElement;
const file = input.files?.[0];
if (!file || !selectedWorkspaceID) return;
const form = new FormData();
form.set("workspace_id", selectedWorkspaceID);
form.set("file", file);
const data = await api<{ upload: Upload }>("/api/uploads", { method: "POST", body: form });
pendingUpload = data.upload;
input.value = "";
}
async function loadDirectConversations() {
if (!selectedWorkspaceID) return;
const data = await api<{ conversations: DirectConversation[] }>(`/api/dms?workspace_id=${selectedWorkspaceID}`);
directConversations = data.conversations;
}
async function createDirectConversation() {
if (!selectedWorkspaceID || !directMemberID.trim()) return;
const data = await api<{ conversation: DirectConversation }>("/api/dms", {
method: "POST",
body: JSON.stringify({ workspace_id: selectedWorkspaceID, member_ids: [directMemberID.trim()] })
});
directMemberID = "";
directConversations = [...directConversations, data.conversation];
selectedDirectID = data.conversation.id;
selectedChannelID = "";
selectedThread = null;
await loadMessages();
}
function connectRealtime() {
socket?.close();
if (!selectedWorkspaceID) return;
const lastCursor = localStorage.getItem(`clickclack:${selectedWorkspaceID}:cursor`) || "";
const url = new URL("/api/realtime/ws", window.location.href);
url.protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("workspace_id", selectedWorkspaceID);
if (lastCursor) url.searchParams.set("after_cursor", lastCursor);
socket = new WebSocket(url);
socket.addEventListener("message", (message) => {
const event = JSON.parse(String(message.data)) as RealtimeEvent;
if (event.cursor) localStorage.setItem(`clickclack:${selectedWorkspaceID}:cursor`, event.cursor);
void handleEvent(event);
});
socket.addEventListener("close", () => {
reconnectTimer = window.setTimeout(connectRealtime, 1200);
});
}
async function handleEvent(event: RealtimeEvent) {
if ((event.type === "channel.created" || event.type === "channel.updated") && event.workspace_id === selectedWorkspaceID) {
await loadChannels();
return;
}
if (
(event.channel_id === selectedChannelID || event.payload.direct_conversation_id === selectedDirectID) &&
(event.type === "message.created" || event.type === "message.updated" || event.type === "message.deleted")
) {
await loadMessages();
}
const rootID = event.payload.root_message_id || event.payload.message_id;
if (selectedThread && rootID === selectedThread.id) {
await openThread(selectedThread);
}
}
</script>
<svelte:head>
<meta name="color-scheme" content="light dark" />
</svelte:head>
<div class="shell">
<aside class="sidebar" aria-label="Workspace and channel navigation">
<div class="brand">
<div class="mark">cc</div>
<div>
<strong>ClickClack</strong>
<span>{user?.display_name || "local"}</span>
</div>
</div>
<section>
<div class="section-title">Workspaces</div>
<div class="nav-list">
{#each workspaces as workspace}
<button
class:active={workspace.id === selectedWorkspaceID}
onclick={async () => {
selectedWorkspaceID = workspace.id;
await loadChannels();
connectRealtime();
}}
>
{workspace.name}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createWorkspace();
}}
>
<input bind:value={workspaceName} placeholder="New workspace" aria-label="New workspace name" />
</form>
</section>
<section>
<div class="section-title">Channels</div>
<div class="nav-list channels">
{#each channels as channel}
<button
class:active={channel.id === selectedChannelID}
onclick={async () => {
selectedChannelID = channel.id;
selectedThread = null;
await loadMessages();
}}
>
<span>#</span>{channel.name}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createChannel();
}}
>
<input bind:value={channelName} placeholder="New channel" aria-label="New channel name" />
</form>
</section>
<section>
<div class="section-title">DMs</div>
<div class="nav-list channels">
{#each directConversations as conversation}
<button
class:active={conversation.id === selectedDirectID}
onclick={async () => {
selectedDirectID = conversation.id;
selectedChannelID = "";
selectedThread = null;
await loadMessages();
}}
>
<span>@</span>{conversation.members.map((member) => member.display_name).join(", ")}
</button>
{/each}
</div>
<form
class="inline-create"
onsubmit={(event) => {
event.preventDefault();
void createDirectConversation();
}}
>
<input bind:value={directMemberID} placeholder="Member user ID" aria-label="DM member user ID" />
</form>
</section>
</aside>
<main class="timeline">
<header class="topbar">
<div>
<p>{selectedWorkspace?.name || "Workspace"}</p>
<h1>{selectedDirect ? "@" + selectedDirect.members.map((member) => member.display_name).join(", ") : "#" + (selectedChannel?.name || "general")}</h1>
</div>
<form
class="search"
onsubmit={(event) => {
event.preventDefault();
void searchMessages();
}}
>
<input bind:value={searchQuery} placeholder="Search" aria-label="Search messages" />
<button type="submit">Search</button>
</form>
<div class="connection" data-state={socket?.readyState === WebSocket.OPEN ? "live" : "idle"}>
{socket?.readyState === WebSocket.OPEN ? "live" : status}
</div>
</header>
{#if searchResults.length > 0}
<div class="search-results" aria-label="Search results">
{#each searchResults as result (result.message.id)}
<button
onclick={async () => {
searchResults = [];
if (result.message.channel_id) {
selectedChannelID = result.message.channel_id;
selectedDirectID = "";
await loadMessages();
}
if (result.message.direct_conversation_id) {
selectedDirectID = result.message.direct_conversation_id;
selectedChannelID = "";
await loadMessages();
}
}}
>
<strong>{result.message.author?.display_name || "Local User"}</strong>
<span>{result.message.body}</span>
</button>
{/each}
</div>
{/if}
<div class="messages" aria-live="polite">
{#if messages.length === 0}
<div class="empty">
<strong>Quiet tide.</strong>
<span>Start with Markdown. Threads open from any root message.</span>
</div>
{/if}
{#each messages as message (message.id)}
<article class="message" class:selected={selectedThread?.id === message.id}>
<div class="avatar">{message.author?.display_name?.slice(0, 1) || "c"}</div>
<div class="message-body">
<header>
<strong>{message.author?.display_name || "Local User"}</strong>
<time>{time(message.created_at)}</time>
</header>
<div class="markdown">{@html markdown(message.body)}</div>
<button class="thread-button" onclick={() => openThread(message)}>Open thread</button>
</div>
</article>
{/each}
</div>
<form
class="composer"
onsubmit={(event) => {
event.preventDefault();
void sendMessage();
}}
>
<textarea bind:value={messageBody} rows="3" placeholder="Message with Markdown" aria-label="Message body"></textarea>
<div class="composer-actions">
<label class="upload-button">
<input type="file" aria-label="Upload file" onchange={uploadFile} />
Upload
</label>
{#if pendingUpload}
<span class="pending-upload">{pendingUpload.filename}</span>
{/if}
<button type="button" onclick={() => void sendMessage()}>Send</button>
</div>
</form>
</main>
<aside class="thread" class:open={selectedThread} aria-label="Thread pane">
{#if selectedThread}
<header>
<div>
<p>Thread</p>
<strong>{selectedThreadState?.reply_count || replies.length} replies</strong>
</div>
<button
aria-label="Close thread"
onclick={() => {
selectedThread = null;
replies = [];
}}
>
x
</button>
</header>
<article class="thread-root">
<strong>{selectedThread.author?.display_name || "Local User"}</strong>
<div class="markdown">{@html markdown(selectedThread.body)}</div>
</article>
<div class="reply-list">
{#each replies as reply (reply.id)}
<article class="reply">
<header>
<strong>{reply.author?.display_name || "Local User"}</strong>
<time>{time(reply.created_at)}</time>
</header>
<div class="markdown">{@html markdown(reply.body)}</div>
</article>
{/each}
</div>
<form
class="reply-composer"
onsubmit={(event) => {
event.preventDefault();
void sendReply();
}}
>
<textarea bind:value={replyBody} rows="3" placeholder="Reply in thread" aria-label="Reply body"></textarea>
<button type="button" onclick={() => void sendReply()}>Reply</button>
</form>
{:else}
<div class="thread-empty">
<strong>No thread open</strong>
<span>Pick a message to keep the side conversation tidy.</span>
</div>
{/if}
</aside>
</div>

11
apps/web/src/lib/api.ts Normal file
View File

@ -0,0 +1,11 @@
export async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
if (init.body && !(init.body instanceof FormData))
headers.set("Content-Type", "application/json");
const response = await fetch(path, { ...init, headers });
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<T>;
}

View File

@ -0,0 +1,12 @@
import DOMPurify from "dompurify";
import { marked } from "marked";
export function markdown(body: string) {
return DOMPurify.sanitize(marked.parse(body, { async: false }));
}
export function time(value: string) {
return new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit" }).format(
new Date(value),
);
}

83
apps/web/src/lib/types.ts Normal file
View File

@ -0,0 +1,83 @@
export type User = {
id: string;
display_name: string;
avatar_url: string;
created_at: string;
};
export type Workspace = {
id: string;
name: string;
slug: string;
created_at: string;
};
export type Channel = {
id: string;
workspace_id: string;
name: string;
kind: string;
created_at: string;
archived_at?: string;
};
export type Message = {
id: string;
workspace_id: string;
channel_id?: string;
direct_conversation_id?: string;
author_id: string;
parent_message_id?: string;
thread_root_id: string;
channel_seq?: number;
thread_seq?: number;
body: string;
body_format: "markdown";
created_at: string;
edited_at?: string;
deleted_at?: string;
author?: User;
};
export type Upload = {
id: string;
filename: string;
byte_size: number;
};
export type SearchResult = {
message: Message;
rank: number;
};
export type DirectConversation = {
id: string;
workspace_id: string;
created_at: string;
members: User[];
};
export type ThreadState = {
root_message_id: string;
reply_count: number;
last_reply_at?: string;
last_reply_author_ids: string[];
};
export type EventPayload = {
message_id?: string;
root_message_id?: string;
channel_id?: string;
direct_conversation_id?: string;
};
export type RealtimeEvent = {
id: string;
cursor: string;
type: string;
workspace_id: string;
channel_id?: string;
seq?: number;
created_at: string;
payload: EventPayload;
};

9
apps/web/src/main.ts Normal file
View File

@ -0,0 +1,9 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./styles.css";
const app = mount(App, {
target: document.getElementById("app")!,
});
export default app;

490
apps/web/src/styles.css Normal file
View File

@ -0,0 +1,490 @@
:root {
color-scheme: light dark;
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f5f1ec;
color: #171717;
--bg: #f5f1ec;
--panel: #fffaf2;
--panel-2: #ece4d8;
--text: #171717;
--muted: #6d655d;
--line: #d8cdbf;
--accent: #dd5d45;
--accent-2: #006d77;
--ink: #102027;
--shadow: 0 18px 60px rgba(16, 32, 39, 0.12);
}
@media (prefers-color-scheme: dark) {
:root {
background: #121416;
color: #f5efe7;
--bg: #121416;
--panel: #1c2022;
--panel-2: #242a2c;
--text: #f5efe7;
--muted: #a59d93;
--line: #343b3e;
--accent: #ff735c;
--accent-2: #6fc7cf;
--ink: #f5efe7;
--shadow: 0 18px 60px rgba(0, 0, 0, 0.35);
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background:
linear-gradient(135deg, rgba(221, 93, 69, 0.12), transparent 34%),
linear-gradient(315deg, rgba(0, 109, 119, 0.12), transparent 32%),
var(--bg);
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
.shell {
display: grid;
grid-template-columns: 260px minmax(0, 1fr) minmax(320px, 28vw);
height: 100vh;
min-height: 620px;
}
.sidebar,
.thread {
background: color-mix(in srgb, var(--panel) 88%, transparent);
border-color: var(--line);
border-style: solid;
backdrop-filter: blur(18px);
}
.sidebar {
border-width: 0 1px 0 0;
padding: 18px 14px;
overflow: auto;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 28px;
}
.mark {
display: grid;
place-items: center;
width: 42px;
height: 42px;
border-radius: 8px;
background: var(--ink);
color: var(--panel);
font-weight: 900;
letter-spacing: 0;
text-transform: uppercase;
}
.brand strong,
.brand span {
display: block;
}
.brand span,
.section-title,
.topbar p,
.thread header p,
time,
.empty span,
.thread-empty span {
color: var(--muted);
font-size: 12px;
}
.section-title {
margin: 18px 8px 8px;
text-transform: uppercase;
font-weight: 800;
}
.nav-list {
display: grid;
gap: 4px;
}
.nav-list button {
width: 100%;
min-height: 36px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text);
text-align: left;
padding: 8px 10px;
}
.nav-list button:hover,
.nav-list button.active {
background: var(--panel-2);
}
.channels button {
display: flex;
gap: 7px;
}
.channels span {
color: var(--accent);
font-weight: 900;
}
.inline-create {
margin: 10px 0;
}
.inline-create input,
textarea {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--text);
outline: 0;
}
.inline-create input {
height: 36px;
padding: 0 10px;
}
.timeline {
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
min-width: 0;
}
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
min-height: 78px;
padding: 14px 22px;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--bg) 82%, transparent);
}
.topbar h1,
.topbar p {
margin: 0;
}
.topbar h1 {
font-size: 22px;
}
.search {
display: flex;
align-items: center;
gap: 8px;
min-width: min(360px, 42vw);
}
.search input {
width: 100%;
min-width: 120px;
height: 34px;
border: 1px solid var(--line);
border-radius: 8px;
background: var(--panel);
color: var(--text);
padding: 0 10px;
}
.search button {
height: 34px;
border: 0;
border-radius: 8px;
background: var(--accent-2);
color: var(--panel);
font-weight: 800;
padding: 0 12px;
}
.connection {
border: 1px solid var(--line);
border-radius: 999px;
color: var(--muted);
padding: 5px 10px;
font-size: 12px;
}
.connection[data-state="live"] {
border-color: color-mix(in srgb, var(--accent-2) 50%, var(--line));
color: var(--accent-2);
}
.search-results {
display: grid;
gap: 4px;
padding: 8px 22px;
border-bottom: 1px solid var(--line);
background: color-mix(in srgb, var(--panel) 86%, transparent);
}
.search-results button {
display: grid;
gap: 2px;
border: 0;
border-radius: 8px;
background: transparent;
color: var(--text);
padding: 8px;
text-align: left;
}
.search-results button:hover {
background: var(--panel-2);
}
.search-results span {
color: var(--muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.messages {
overflow: auto;
padding: 18px 22px 28px;
}
.empty,
.thread-empty {
display: grid;
place-items: center;
align-content: center;
min-height: 220px;
text-align: center;
gap: 6px;
}
.message {
display: grid;
grid-template-columns: 38px minmax(0, 1fr);
gap: 10px;
padding: 12px 8px;
border-radius: 8px;
}
.message:hover,
.message.selected {
background: color-mix(in srgb, var(--panel-2) 62%, transparent);
}
.avatar {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 8px;
background: var(--accent);
color: white;
font-weight: 900;
text-transform: uppercase;
}
.message header,
.reply header {
display: flex;
align-items: baseline;
gap: 8px;
}
.markdown {
line-height: 1.45;
overflow-wrap: anywhere;
}
.markdown p {
margin: 4px 0 0;
}
.markdown code {
border-radius: 5px;
background: var(--panel-2);
padding: 1px 4px;
}
.thread-button {
border: 0;
background: transparent;
color: var(--accent-2);
margin-top: 5px;
padding: 0;
font-size: 12px;
font-weight: 800;
}
.composer,
.reply-composer {
display: grid;
gap: 10px;
padding: 14px;
border-top: 1px solid var(--line);
background: color-mix(in srgb, var(--panel) 72%, transparent);
}
.composer {
grid-template-columns: minmax(0, 1fr) minmax(104px, auto);
align-items: end;
}
textarea {
resize: vertical;
min-height: 70px;
max-height: 190px;
padding: 11px 12px;
}
.composer button,
.reply-composer button {
min-height: 42px;
border: 0;
border-radius: 8px;
background: var(--ink);
color: var(--panel);
font-weight: 900;
}
.composer-actions {
display: grid;
gap: 8px;
min-width: 104px;
}
.upload-button {
display: grid;
place-items: center;
min-height: 36px;
border: 1px solid var(--line);
border-radius: 8px;
color: var(--accent-2);
font-size: 12px;
font-weight: 900;
}
.upload-button input {
position: absolute;
inline-size: 1px;
block-size: 1px;
opacity: 0;
}
.pending-upload {
color: var(--muted);
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.thread {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr) auto;
border-width: 0 0 0 1px;
min-width: 0;
}
.thread > header {
display: flex;
align-items: center;
justify-content: space-between;
min-height: 72px;
padding: 14px;
border-bottom: 1px solid var(--line);
}
.thread > header p,
.thread > header strong {
margin: 0;
}
.thread > header button {
width: 32px;
height: 32px;
border: 1px solid var(--line);
border-radius: 8px;
background: transparent;
color: var(--text);
}
.thread-root {
padding: 14px;
border-bottom: 1px solid var(--line);
}
.reply-list {
overflow: auto;
padding: 8px 14px;
}
.reply {
padding: 10px 0;
border-bottom: 1px solid color-mix(in srgb, var(--line) 65%, transparent);
}
@media (max-width: 980px) {
.shell {
grid-template-columns: 220px minmax(0, 1fr);
}
.thread {
position: fixed;
inset: 0 0 0 auto;
width: min(420px, 100vw);
box-shadow: var(--shadow);
transform: translateX(100%);
transition: transform 160ms ease;
z-index: 4;
}
.thread.open {
transform: translateX(0);
}
}
@media (max-width: 720px) {
.shell {
grid-template-columns: 1fr;
min-height: 100vh;
}
.sidebar {
display: none;
}
.composer {
grid-template-columns: 1fr;
}
.topbar {
align-items: stretch;
flex-direction: column;
}
.search {
min-width: 0;
width: 100%;
}
}

1
apps/web/src/vite-env.d.ts vendored Normal file
View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

13
apps/web/tsconfig.json Normal file
View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"allowJs": false,
"checkJs": false,
"isolatedModules": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"target": "ES2022",
"types": ["svelte"]
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}

11
apps/web/vite.config.ts Normal file
View File

@ -0,0 +1,11 @@
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig } from "vite";
export default defineConfig({
plugins: [svelte()],
server: {
proxy: {
"/api": "http://127.0.0.1:8080"
}
}
});

23
docs/api/overview.md Normal file
View File

@ -0,0 +1,23 @@
---
read_when:
- changing REST endpoints, websocket behavior, SDK methods, or OpenAPI
- adding integrations or bots
---
# API Overview
`packages/protocol/openapi.yaml` is the API contract source of truth.
Main API groups:
- Auth: local dev headers, bearer sessions, magic-link token request/consume, optional GitHub OAuth.
- Workspaces/channels: create/list workspaces and create/list/update channels.
- Messages: create/list/update/soft-delete root channel messages, reactions, attachments.
- Threads: one-level thread replies per root message.
- Realtime: websocket notifications, HTTP event recovery by cursor, and non-durable typing/presence publish.
- Search/uploads/DMs: SQLite FTS5 search, local upload storage, direct conversations.
- Integrations: Mattermost-compatible incoming webhook shape and simple slash command callbacks.
TypeScript consumers should use `@clickclack/sdk-ts`. The SDK has no Svelte dependency and exposes HTTP helpers plus `events.subscribe(...)`.
The bot example in `examples/bot-ts` sends a channel message using the SDK and either `CLICKCLACK_TOKEN` or `CLICKCLACK_USER_ID`.

View File

@ -0,0 +1,29 @@
---
read_when:
- changing backend storage, realtime events, auth, or embedded serving
- adding a second database implementation
---
# Architecture Overview
ClickClack ships as one Go binary serving a Svelte SPA, JSON API, websocket endpoint, embedded SQLite migrations, local SQLite data, and local upload files.
Durable state lives in SQLite. WebSockets are an update pipe only; clients recover missed durable events through `GET /api/realtime/events?after_cursor=...`.
Core layers:
- `apps/api/cmd/clickclack`: CLI and single-binary entrypoint.
- `apps/api/internal/httpapi`: chi routes, auth, API handlers, SPA serving.
- `apps/api/internal/store`: backend-facing store contracts and domain types.
- `apps/api/internal/store/sqlite`: SQLite implementation, embedded migrations, backups, exports.
- `apps/api/internal/realtime`: in-process workspace event hub.
- `apps/web`: Svelte 5 SPA with API-only client behavior.
- `packages/protocol`: OpenAPI contract.
- `packages/sdk-ts`: generated OpenAPI types plus framework-neutral TypeScript wrapper.
Storage constraints:
- SQLite uses `modernc.org/sqlite` and WAL.
- Transactions stay short and issue outbox events inside the same commit as durable writes.
- IDs are sortable ULID text with semantic prefixes.
- Postgres should be added behind the store layer without changing API handlers.

13
examples/bot-ts/README.md Normal file
View File

@ -0,0 +1,13 @@
# ClickClack Bot Example
Tiny TypeScript bot using `@clickclack/sdk-ts`.
```sh
CLICKCLACK_URL=http://localhost:8080 \
CLICKCLACK_USER_ID=user_dev \
CLICKCLACK_CHANNEL_ID=chan_... \
CLICKCLACK_TEXT="clack from bot" \
pnpm --filter @clickclack/example-bot start
```
Use `CLICKCLACK_TOKEN` instead of `CLICKCLACK_USER_ID` when running with a bearer session token.

View File

@ -0,0 +1,18 @@
{
"name": "@clickclack/example-bot",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"scripts": {
"start": "pnpm --filter @clickclack/sdk-ts build && node --experimental-strip-types src/index.ts",
"typecheck": "tsgo --noEmit -p tsconfig.json"
},
"dependencies": {
"@clickclack/sdk-ts": "workspace:*"
},
"devDependencies": {
"@types/node": "^25.6.2",
"@typescript/native-preview": "7.0.0-dev.20260507.1"
}
}

View File

@ -0,0 +1,20 @@
import { ClickClackClient } from "@clickclack/sdk-ts";
const baseUrl = requiredEnv("CLICKCLACK_URL");
const channelId = requiredEnv("CLICKCLACK_CHANNEL_ID");
const text = process.env.CLICKCLACK_TEXT ?? "clack from bot";
const client = new ClickClackClient({
baseUrl,
token: process.env.CLICKCLACK_TOKEN,
userId: process.env.CLICKCLACK_USER_ID,
});
const message = await client.channels.sendMessage(channelId, { body: text });
console.log(JSON.stringify({ message_id: message.id, channel_seq: message.channel_seq }));
function requiredEnv(name: string): string {
const value = process.env[name];
if (!value) throw new Error(`missing ${name}`);
return value;
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@clickclack/sdk-ts": ["../../packages/sdk-ts/src/index.ts"]
},
"strict": true,
"target": "ES2022",
"types": ["node"]
},
"include": ["src/**/*.ts"]
}

26
go.mod Normal file
View File

@ -0,0 +1,26 @@
module github.com/openclaw/clickclack
go 1.26
require (
github.com/coder/websocket v1.8.14
github.com/go-chi/chi/v5 v5.2.5
github.com/oklog/ulid/v2 v2.1.1
modernc.org/sqlite v1.50.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/tools v0.44.0 // indirect
modernc.org/cc/v4 v4.28.2 // indirect
modernc.org/gc/v3 v3.1.3 // indirect
modernc.org/libc v1.72.2 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

57
go.sum Normal file
View File

@ -0,0 +1,57 @@
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96 h1:YDDnaZ9afWajDboPMt9Vikqca/yWAX7KAxVzb4lJU1M=
github.com/google/pprof v0.0.0-20260507013755-92041b743c96/go.mod h1:MxpfABSjhmINe3F1It9d+8exIHFvUqtLIRCdOGNXqiI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4=
github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s=
github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ=
github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
modernc.org/cc/v4 v4.28.2 h1:3tQ0lf2ADtoby2EtSP+J7IE2SHwEJdP8ioR59wx7XpY=
modernc.org/cc/v4 v4.28.2/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.0 h1:yRLPFZieg532OT4rp4JFNIVcquwalMX26G95WQDqwCQ=
modernc.org/ccgo/v4 v4.34.0/go.mod h1:AS5WYMyBakQ+fhsHhtP8mWB82KTGPkNNJDGfGQCe0/A=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.72.2 h1:HwRjrHwX7hZIFCfRyw6otVlY+BoZEHFQmHQa5B0BzDE=
modernc.org/libc v1.72.2/go.mod h1:43RZAMuEX483KwP1bW+3lTFm3dzwFpl6R8HMEutqy/w=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.50.0 h1:eMowQSWLK0MeiQTdmz3lqoF5dqclujdlIKeJA11+7oM=
modernc.org/sqlite v1.50.0/go.mod h1:m0w8xhwYUVY3H6pSDwc3gkJ/irZT/0YEXwBlhaxQEew=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

View File

@ -0,0 +1,150 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY,
display_name TEXT NOT NULL,
avatar_url TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS identities (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
provider TEXT NOT NULL,
provider_subject TEXT NOT NULL,
email TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
UNIQUE(provider, provider_subject)
);
CREATE TABLE IF NOT EXISTS workspaces (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS workspace_members (
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, user_id)
);
CREATE TABLE IF NOT EXISTS channels (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
name TEXT NOT NULL,
kind TEXT NOT NULL,
created_at TEXT NOT NULL,
archived_at TEXT,
UNIQUE(workspace_id, name)
);
CREATE TABLE IF NOT EXISTS messages (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
channel_id TEXT REFERENCES channels(id) ON DELETE CASCADE,
direct_conversation_id TEXT,
author_id TEXT NOT NULL REFERENCES users(id),
parent_message_id TEXT REFERENCES messages(id) ON DELETE CASCADE,
thread_root_id TEXT NOT NULL,
channel_seq INTEGER,
thread_seq INTEGER,
body TEXT NOT NULL,
body_format TEXT NOT NULL,
created_at TEXT NOT NULL,
edited_at TEXT,
deleted_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_messages_channel_seq ON messages(channel_id, channel_seq);
CREATE INDEX IF NOT EXISTS idx_messages_thread_seq ON messages(thread_root_id, thread_seq);
CREATE INDEX IF NOT EXISTS idx_messages_direct_seq ON messages(direct_conversation_id, channel_seq);
CREATE VIRTUAL TABLE IF NOT EXISTS messages_fts USING fts5(
message_id UNINDEXED,
workspace_id UNINDEXED,
body,
tokenize = 'porter unicode61'
);
CREATE TRIGGER IF NOT EXISTS messages_fts_ai AFTER INSERT ON messages BEGIN
INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body);
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_ad AFTER DELETE ON messages BEGIN
DELETE FROM messages_fts WHERE message_id = old.id;
END;
CREATE TRIGGER IF NOT EXISTS messages_fts_au AFTER UPDATE OF body ON messages BEGIN
DELETE FROM messages_fts WHERE message_id = old.id;
INSERT INTO messages_fts(message_id, workspace_id, body) VALUES (new.id, new.workspace_id, new.body);
END;
CREATE TABLE IF NOT EXISTS thread_state (
root_message_id TEXT PRIMARY KEY REFERENCES messages(id) ON DELETE CASCADE,
reply_count INTEGER NOT NULL DEFAULT 0,
last_reply_at TEXT,
last_reply_author_ids_json TEXT NOT NULL DEFAULT '[]'
);
CREATE TABLE IF NOT EXISTS reactions (
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
emoji TEXT NOT NULL,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, user_id, emoji)
);
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
cursor TEXT NOT NULL UNIQUE,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
channel_id TEXT,
type TEXT NOT NULL,
seq INTEGER,
payload_json TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_workspace_cursor ON events(workspace_id, cursor);
CREATE TABLE IF NOT EXISTS uploads (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
owner_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
filename TEXT NOT NULL,
content_type TEXT NOT NULL,
byte_size INTEGER NOT NULL,
storage_path TEXT NOT NULL,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS message_attachments (
message_id TEXT NOT NULL REFERENCES messages(id) ON DELETE CASCADE,
upload_id TEXT NOT NULL REFERENCES uploads(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (message_id, upload_id)
);
CREATE TABLE IF NOT EXISTS direct_conversations (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
created_at TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS direct_conversation_members (
conversation_id TEXT NOT NULL REFERENCES direct_conversations(id) ON DELETE CASCADE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY (conversation_id, user_id)
);
CREATE TABLE IF NOT EXISTS invites (
id TEXT PRIMARY KEY,
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE,
token TEXT NOT NULL UNIQUE,
created_by TEXT NOT NULL REFERENCES users(id),
created_at TEXT NOT NULL,
accepted_at TEXT
);

View File

@ -0,0 +1,22 @@
CREATE TABLE IF NOT EXISTS auth_magic_links (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
email TEXT NOT NULL,
display_name TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
used_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_auth_magic_links_token ON auth_magic_links(token);
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
created_at TEXT NOT NULL,
expires_at TEXT NOT NULL,
revoked_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token);

25
package.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "clickclack",
"private": true,
"license": "MIT",
"scripts": {
"build": "pnpm --filter @clickclack/web build && pnpm --filter @clickclack/sdk-ts build && rm -rf apps/api/internal/webassets/dist && cp -R apps/web/dist apps/api/internal/webassets/dist",
"check": "go test ./... && pnpm -r typecheck && pnpm lint",
"coverage": "go test ./apps/api/internal/... -coverprofile=coverage.out && go tool cover -func=coverage.out | tee coverage.txt && awk '/^total:/ { sub(/%/, \"\", $3); if ($3 + 0 < 90) exit 1 }' coverage.txt",
"dev:web": "pnpm --filter @clickclack/web dev",
"dev:api": "go run ./apps/api/cmd/clickclack serve",
"fmt": "gofmt -w apps/api && oxfmt --write \"apps/web/src/**/*.{ts,svelte}\" \"packages/sdk-ts/src/**/*.ts\" \"examples/**/*.ts\"",
"lint": "oxlint apps/web/src packages/sdk-ts/src examples tests/e2e playwright.config.ts",
"test:e2e": "playwright test",
"typecheck": "tsgo --noEmit -p tsconfig.json",
"test": "go test ./... && pnpm build"
},
"packageManager": "pnpm@11.0.7",
"devDependencies": {
"@playwright/test": "^1.59.1",
"@types/node": "^25.6.2",
"@typescript/native-preview": "7.0.0-dev.20260507.1",
"oxfmt": "^0.48.0",
"oxlint": "^1.63.0"
}
}

View File

@ -0,0 +1,542 @@
openapi: 3.1.0
info:
title: ClickClack API
version: 0.1.0
license:
name: MIT
servers:
- url: http://localhost:8080
paths:
/api/auth/magic/request:
post:
operationId: requestMagicLink
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/RequestMagicLinkRequest"
responses:
"201":
description: Created local magic-link token
/api/auth/magic/consume:
post:
operationId: consumeMagicLink
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ConsumeMagicLinkRequest"
responses:
"200":
description: Created session
/api/auth/github/start:
get:
operationId: startGitHubOAuth
responses:
"302":
description: Redirect to GitHub OAuth authorization
"501":
description: GitHub OAuth not configured
/api/auth/github/callback:
get:
operationId: finishGitHubOAuth
parameters:
- name: code
in: query
schema:
type: string
- name: state
in: query
schema:
type: string
responses:
"302":
description: Session created and redirected to app
"400":
description: Invalid OAuth callback
/api/me:
get:
operationId: getMe
responses:
"200":
description: Current local user
/api/workspaces:
get:
operationId: listWorkspaces
responses:
"200":
description: Workspace list
post:
operationId: createWorkspace
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateWorkspaceRequest"
responses:
"201":
description: Created workspace
/api/workspaces/{workspace_id}:
get:
operationId: getWorkspace
parameters:
- $ref: "#/components/parameters/workspace_id"
responses:
"200":
description: Workspace
/api/workspaces/{workspace_id}/channels:
get:
operationId: listChannels
parameters:
- $ref: "#/components/parameters/workspace_id"
responses:
"200":
description: Channel list
post:
operationId: createChannel
parameters:
- $ref: "#/components/parameters/workspace_id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateChannelRequest"
responses:
"201":
description: Created channel
/api/channels/{channel_id}:
patch:
operationId: updateChannel
parameters:
- $ref: "#/components/parameters/channel_id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UpdateChannelRequest"
responses:
"200":
description: Updated channel
/api/channels/{channel_id}/messages:
get:
operationId: listMessages
parameters:
- $ref: "#/components/parameters/channel_id"
- name: after_seq
in: query
schema:
type: integer
- name: limit
in: query
schema:
type: integer
responses:
"200":
description: Root channel messages
post:
operationId: createMessage
parameters:
- $ref: "#/components/parameters/channel_id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateMessageRequest"
responses:
"201":
description: Created message
/api/messages/{message_id}:
patch:
operationId: updateMessage
parameters:
- $ref: "#/components/parameters/message_id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateMessageRequest"
responses:
"200":
description: Updated message
delete:
operationId: deleteMessage
parameters:
- $ref: "#/components/parameters/message_id"
responses:
"200":
description: Soft-deleted message
/api/messages/{message_id}/thread:
get:
operationId: getThread
parameters:
- $ref: "#/components/parameters/message_id"
responses:
"200":
description: Thread root and replies
/api/messages/{message_id}/thread/replies:
post:
operationId: createThreadReply
parameters:
- $ref: "#/components/parameters/message_id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateMessageRequest"
responses:
"201":
description: Created thread reply
/api/messages/{message_id}/reactions:
post:
operationId: addReaction
parameters:
- $ref: "#/components/parameters/message_id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AddReactionRequest"
responses:
"201":
description: Added reaction
/api/messages/{message_id}/attachments:
post:
operationId: attachUpload
parameters:
- $ref: "#/components/parameters/message_id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/AttachUploadRequest"
responses:
"200":
description: Attached upload to message
/api/messages/{message_id}/reactions/{emoji}:
delete:
operationId: removeReaction
parameters:
- $ref: "#/components/parameters/message_id"
- name: emoji
in: path
required: true
schema:
type: string
responses:
"200":
description: Removed reaction
/api/realtime/events:
get:
operationId: listEvents
parameters:
- name: workspace_id
in: query
required: true
schema:
type: string
- name: after_cursor
in: query
schema:
type: string
- name: limit
in: query
schema:
type: integer
responses:
"200":
description: Durable events after cursor
/api/realtime/ephemeral:
post:
operationId: publishEphemeral
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/EphemeralEventRequest"
responses:
"202":
description: Ephemeral event accepted
/api/realtime/ws:
get:
operationId: realtimeWebSocket
parameters:
- name: workspace_id
in: query
required: true
schema:
type: string
- name: after_cursor
in: query
schema:
type: string
responses:
"101":
description: WebSocket upgrade
/api/search:
get:
operationId: search
parameters:
- name: workspace_id
in: query
required: true
schema:
type: string
- name: q
in: query
required: true
schema:
type: string
responses:
"200":
description: Search results
/api/uploads:
post:
operationId: createUpload
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [workspace_id, file]
properties:
workspace_id:
type: string
file:
type: string
format: binary
responses:
"201":
description: Created upload
/api/uploads/{upload_id}:
get:
operationId: getUpload
parameters:
- name: upload_id
in: path
required: true
schema:
type: string
responses:
"200":
description: Upload bytes
/api/dms:
get:
operationId: listDirectConversations
parameters:
- name: workspace_id
in: query
required: true
schema:
type: string
responses:
"200":
description: DM list
post:
operationId: createDirectConversation
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateDirectConversationRequest"
responses:
"201":
description: Created DM
/api/dms/{conversation_id}/messages:
get:
operationId: listDirectMessages
parameters:
- $ref: "#/components/parameters/conversation_id"
- name: after_seq
in: query
schema:
type: integer
- name: limit
in: query
schema:
type: integer
responses:
"200":
description: Direct messages
post:
operationId: createDirectMessage
parameters:
- $ref: "#/components/parameters/conversation_id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/CreateMessageRequest"
responses:
"201":
description: Created direct message
/api/hooks/mattermost/{channel_id}:
post:
operationId: mattermostIncomingWebhook
parameters:
- $ref: "#/components/parameters/channel_id"
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/MattermostWebhookRequest"
responses:
"201":
description: Created message from incoming webhook
/api/hooks/slash/{channel_id}:
post:
operationId: slashCommand
parameters:
- $ref: "#/components/parameters/channel_id"
requestBody:
required: true
content:
application/x-www-form-urlencoded:
schema:
$ref: "#/components/schemas/SlashCommandRequest"
responses:
"201":
description: Slash-command callback response
components:
parameters:
workspace_id:
name: workspace_id
in: path
required: true
schema:
type: string
channel_id:
name: channel_id
in: path
required: true
schema:
type: string
message_id:
name: message_id
in: path
required: true
schema:
type: string
conversation_id:
name: conversation_id
in: path
required: true
schema:
type: string
schemas:
CreateWorkspaceRequest:
type: object
required: [name]
properties:
name:
type: string
slug:
type: string
RequestMagicLinkRequest:
type: object
required: [email]
properties:
email:
type: string
format: email
display_name:
type: string
ConsumeMagicLinkRequest:
type: object
required: [token]
properties:
token:
type: string
CreateChannelRequest:
type: object
required: [name]
properties:
name:
type: string
kind:
type: string
default: public
UpdateChannelRequest:
type: object
properties:
name:
type: string
kind:
type: string
archived:
type: boolean
CreateMessageRequest:
type: object
required: [body]
properties:
body:
type: string
body_format:
type: string
enum: [markdown]
default: markdown
AddReactionRequest:
type: object
required: [emoji]
properties:
emoji:
type: string
AttachUploadRequest:
type: object
required: [upload_id]
properties:
upload_id:
type: string
CreateDirectConversationRequest:
type: object
required: [workspace_id, member_ids]
properties:
workspace_id:
type: string
member_ids:
type: array
items:
type: string
EphemeralEventRequest:
type: object
required: [workspace_id, type]
properties:
workspace_id:
type: string
channel_id:
type: string
type:
type: string
enum: [typing.started, typing.stopped, presence.changed]
payload:
type: object
additionalProperties: true
MattermostWebhookRequest:
type: object
required: [text]
properties:
text:
type: string
SlashCommandRequest:
type: object
properties:
command:
type: string
text:
type: string
user_name:
type: string

View File

@ -0,0 +1,15 @@
{
"name": "@clickclack/protocol",
"version": "0.0.0",
"private": true,
"license": "MIT",
"scripts": {
"generate": "openapi-typescript openapi.yaml -o ../sdk-ts/src/generated/openapi.d.ts"
},
"devDependencies": {
"openapi-typescript": "^7.13.0"
},
"files": [
"openapi.yaml"
]
}

View File

@ -0,0 +1,16 @@
{
"name": "@clickclack/sdk-ts",
"version": "0.0.0",
"private": true,
"license": "MIT",
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"scripts": {
"build": "tsgo -p tsconfig.json && mkdir -p dist/generated && cp src/generated/openapi.d.ts dist/generated/openapi.d.ts",
"typecheck": "tsgo --noEmit -p tsconfig.json"
},
"devDependencies": {
"@typescript/native-preview": "7.0.0-dev.20260507.1"
}
}

1214
packages/sdk-ts/src/generated/openapi.d.ts vendored Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,323 @@
export type { components, paths } from "./generated/openapi";
export type User = {
id: string;
display_name: string;
avatar_url: string;
created_at: string;
};
export type Workspace = {
id: string;
name: string;
slug: string;
created_at: string;
};
export type Channel = {
id: string;
workspace_id: string;
name: string;
kind: string;
created_at: string;
archived_at?: string;
};
export type Message = {
id: string;
workspace_id: string;
channel_id: string;
author_id: string;
parent_message_id?: string;
thread_root_id: string;
channel_seq?: number;
thread_seq?: number;
body: string;
body_format: "markdown";
created_at: string;
edited_at?: string;
deleted_at?: string;
author?: User;
};
export type Upload = {
id: string;
workspace_id: string;
owner_id: string;
filename: string;
content_type: string;
byte_size: number;
created_at: string;
};
export type DirectConversation = {
id: string;
workspace_id: string;
created_at: string;
members: User[];
};
export type RealtimeEvent = {
id: string;
cursor: string;
type: string;
workspace_id: string;
channel_id?: string;
seq?: number;
created_at: string;
payload: unknown;
};
export type ClickClackClientOptions = {
baseUrl: string;
userId?: string;
token?: string;
fetch?: typeof fetch;
};
export class ClickClackClient {
private readonly baseUrl: string;
private readonly userId?: string;
private token?: string;
private readonly fetcher: typeof fetch;
constructor(options: ClickClackClientOptions) {
this.baseUrl = options.baseUrl.replace(/\/$/, "");
this.userId = options.userId;
this.token = options.token;
this.fetcher = options.fetch ?? fetch;
}
auth = {
requestMagicLink: async (input: { email: string; display_name?: string }) => {
return this.request("/api/auth/magic/request", {
method: "POST",
body: JSON.stringify(input),
});
},
consumeMagicLink: async (
token: string,
): Promise<{ user: User; session: { token: string } }> => {
const data = await this.request<{ user: User; session: { token: string } }>(
"/api/auth/magic/consume",
{
method: "POST",
body: JSON.stringify({ token }),
},
);
this.token = data.session.token;
return data;
},
setToken: (token: string) => {
this.token = token;
},
githubStartUrl: (): string => {
return `${this.baseUrl}/api/auth/github/start`;
},
};
async me(): Promise<User> {
const data = await this.request<{ user: User }>("/api/me");
return data.user;
}
workspaces = {
list: async (): Promise<Workspace[]> => {
const data = await this.request<{ workspaces: Workspace[] }>("/api/workspaces");
return data.workspaces;
},
create: async (input: { name: string; slug?: string }): Promise<Workspace> => {
const data = await this.request<{ workspace: Workspace }>("/api/workspaces", {
method: "POST",
body: JSON.stringify(input),
});
return data.workspace;
},
};
channels = {
list: async (workspaceId: string): Promise<Channel[]> => {
const data = await this.request<{ channels: Channel[] }>(
`/api/workspaces/${workspaceId}/channels`,
);
return data.channels;
},
create: async (
workspaceId: string,
input: { name: string; kind?: string },
): Promise<Channel> => {
const data = await this.request<{ channel: Channel }>(
`/api/workspaces/${workspaceId}/channels`,
{
method: "POST",
body: JSON.stringify(input),
},
);
return data.channel;
},
update: async (
channelId: string,
input: { name?: string; kind?: string; archived?: boolean },
): Promise<Channel> => {
const data = await this.request<{ channel: Channel }>(`/api/channels/${channelId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
return data.channel;
},
messages: async (channelId: string, afterSeq = 0): Promise<Message[]> => {
const data = await this.request<{ messages: Message[] }>(
`/api/channels/${channelId}/messages?after_seq=${afterSeq}`,
);
return data.messages;
},
sendMessage: async (channelId: string, input: { body: string }): Promise<Message> => {
const data = await this.request<{ message: Message }>(`/api/channels/${channelId}/messages`, {
method: "POST",
body: JSON.stringify(input),
});
return data.message;
},
};
messages = {
update: async (messageId: string, input: { body: string }): Promise<Message> => {
const data = await this.request<{ message: Message }>(`/api/messages/${messageId}`, {
method: "PATCH",
body: JSON.stringify(input),
});
return data.message;
},
delete: async (messageId: string): Promise<Message> => {
const data = await this.request<{ message: Message }>(`/api/messages/${messageId}`, {
method: "DELETE",
});
return data.message;
},
};
threads = {
get: async (messageId: string) => {
return this.request(`/api/messages/${messageId}/thread`);
},
reply: async (messageId: string, input: { body: string }): Promise<Message> => {
const data = await this.request<{ message: Message }>(
`/api/messages/${messageId}/thread/replies`,
{
method: "POST",
body: JSON.stringify(input),
},
);
return data.message;
},
};
search = async (workspaceId: string, query: string) => {
return this.request(
`/api/search?workspace_id=${encodeURIComponent(workspaceId)}&q=${encodeURIComponent(query)}`,
);
};
uploads = {
create: async (
workspaceId: string,
file: File | Blob,
filename = "upload.bin",
): Promise<Upload> => {
const form = new FormData();
form.set("workspace_id", workspaceId);
form.set("file", file, filename);
const data = await this.request<{ upload: Upload }>("/api/uploads", {
method: "POST",
body: form,
});
return data.upload;
},
attach: async (messageId: string, uploadId: string): Promise<void> => {
await this.request(`/api/messages/${messageId}/attachments`, {
method: "POST",
body: JSON.stringify({ upload_id: uploadId }),
});
},
};
dms = {
list: async (workspaceId: string): Promise<DirectConversation[]> => {
const data = await this.request<{ conversations: DirectConversation[] }>(
`/api/dms?workspace_id=${encodeURIComponent(workspaceId)}`,
);
return data.conversations;
},
create: async (workspaceId: string, memberIds: string[]): Promise<DirectConversation> => {
const data = await this.request<{ conversation: DirectConversation }>("/api/dms", {
method: "POST",
body: JSON.stringify({ workspace_id: workspaceId, member_ids: memberIds }),
});
return data.conversation;
},
messages: async (conversationId: string, afterSeq = 0): Promise<Message[]> => {
const data = await this.request<{ messages: Message[] }>(
`/api/dms/${conversationId}/messages?after_seq=${afterSeq}`,
);
return data.messages;
},
sendMessage: async (conversationId: string, input: { body: string }): Promise<Message> => {
const data = await this.request<{ message: Message }>(`/api/dms/${conversationId}/messages`, {
method: "POST",
body: JSON.stringify(input),
});
return data.message;
},
};
events = {
publishEphemeral: async (input: {
workspaceId: string;
channelId?: string;
type: "typing.started" | "typing.stopped" | "presence.changed";
payload?: Record<string, unknown>;
}): Promise<RealtimeEvent> => {
const data = await this.request<{ event: RealtimeEvent }>("/api/realtime/ephemeral", {
method: "POST",
body: JSON.stringify({
workspace_id: input.workspaceId,
channel_id: input.channelId,
type: input.type,
payload: input.payload,
}),
});
return data.event;
},
subscribe: (options: {
workspaceId: string;
afterCursor?: string;
onEvent: (event: RealtimeEvent) => void;
onClose?: () => void;
}): WebSocket => {
const url = new URL(`${this.baseUrl}/api/realtime/ws`);
url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
url.searchParams.set("workspace_id", options.workspaceId);
if (options.afterCursor) url.searchParams.set("after_cursor", options.afterCursor);
const socket = new WebSocket(url);
socket.addEventListener("message", (message) =>
options.onEvent(JSON.parse(String(message.data))),
);
if (options.onClose) socket.addEventListener("close", options.onClose);
return socket;
},
};
private async request<T>(path: string, init: RequestInit = {}): Promise<T> {
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
if (init.body && !(init.body instanceof FormData))
headers.set("Content-Type", "application/json");
if (this.token) headers.set("Authorization", `Bearer ${this.token}`);
if (this.userId) headers.set("X-ClickClack-User", this.userId);
const response = await this.fetcher(`${this.baseUrl}${path}`, { ...init, headers });
if (!response.ok) {
throw new Error(await response.text());
}
return response.json() as Promise<T>;
}
}

View File

@ -0,0 +1,13 @@
{
"compilerOptions": {
"declaration": true,
"emitDeclarationOnly": false,
"module": "ESNext",
"moduleResolution": "Bundler",
"outDir": "dist",
"rootDir": "src",
"strict": true,
"target": "ES2022"
},
"include": ["src/**/*.ts"]
}

26
playwright.config.ts Normal file
View File

@ -0,0 +1,26 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "tests/e2e",
timeout: 30_000,
expect: {
timeout: 5_000,
},
use: {
baseURL: "http://127.0.0.1:18082",
trace: "on-first-retry",
},
webServer: {
command:
"rm -rf data/e2e && pnpm build && go run ./apps/api/cmd/clickclack serve --addr 127.0.0.1:18082 --data ./data/e2e",
url: "http://127.0.0.1:18082",
reuseExistingServer: false,
timeout: 30_000,
},
projects: [
{
name: "chromium",
use: { ...devices["Desktop Chrome"] },
},
],
});

1718
pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

6
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,6 @@
packages:
- apps/*
- examples/*
- packages/*
allowBuilds:
esbuild: true

75
tests/e2e/chat.spec.ts Normal file
View File

@ -0,0 +1,75 @@
import { expect, test } from "@playwright/test";
import { execFileSync } from "node:child_process";
test("sends messages, searches, uploads, opens a thread, and creates a DM", async ({ page }) => {
const consoleMessages: string[] = [];
page.on("console", (message) => consoleMessages.push(`${message.type()}: ${message.text()}`));
page.on("pageerror", (error) => consoleMessages.push(`pageerror: ${error.message}`));
const workspacesResponse = await page.request.get("/api/workspaces");
const workspaces = (await workspacesResponse.json()) as { workspaces: { id: string }[] };
const workspaceId = workspaces.workspaces[0].id;
const secondUserId = execFileSync(
"go",
[
"run",
"./apps/api/cmd/clickclack",
"admin",
"user",
"create",
"--data",
"./data/e2e",
"--workspace",
workspaceId,
"--name",
"Second User",
"--email",
"second@example.com",
],
{ cwd: process.cwd(), encoding: "utf8" },
).trim();
await page.goto("/");
await page.getByRole("button", { name: "# general" }).click();
await expect(page.getByRole("heading", { name: "#general" })).toBeVisible();
await page.getByLabel("Message body").fill("hello **playwright**");
await page.getByRole("button", { name: "Send" }).click();
await expect(
page.locator(".markdown").filter({ hasText: "hello playwright" }),
consoleMessages.join("\n"),
).toBeVisible({
timeout: 5_000,
});
await page.getByLabel("Search messages").fill("playwright");
await page.getByRole("button", { name: "Search" }).click();
await expect(page.getByLabel("Search results").getByText("hello **playwright**")).toBeVisible();
await page.getByLabel("Upload file").setInputFiles({
name: "note.txt",
mimeType: "text/plain",
buffer: Buffer.from("uploaded from playwright"),
});
await expect(page.getByText("note.txt")).toBeVisible();
await page.getByLabel("Message body").fill("message with upload");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.locator(".markdown").filter({ hasText: "message with upload" })).toBeVisible();
await page.getByRole("button", { name: "Open thread" }).first().click();
await expect(page.getByText("Thread", { exact: true })).toBeVisible();
await page.getByLabel("Reply body").fill("thread _reply_");
await page.getByRole("button", { name: "Reply" }).click();
await expect(page.locator(".reply .markdown").filter({ hasText: "thread reply" })).toBeVisible();
await page.reload();
await expect(page.locator(".markdown").filter({ hasText: "hello playwright" })).toBeVisible();
await page.getByLabel("DM member user ID").fill(secondUserId);
await page.getByLabel("DM member user ID").press("Enter");
await expect(page.getByRole("heading", { name: /Second User/ })).toBeVisible();
await page.getByLabel("Message body").fill("private playwright");
await page.getByRole("button", { name: "Send" }).click();
await expect(page.locator(".markdown").filter({ hasText: "private playwright" })).toBeVisible();
});

10
tsconfig.json Normal file
View File

@ -0,0 +1,10 @@
{
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"target": "ES2022",
"types": ["node"]
},
"include": ["playwright.config.ts", "tests/**/*.ts"]
}