feat: build initial clickclack app
This commit is contained in:
parent
66cb07fb1a
commit
5cea1a52cb
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal 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
3
.oxfmtrc.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"lineWidth": 120
|
||||
}
|
||||
31
Dockerfile
Normal file
31
Dockerfile
Normal 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"]
|
||||
31
README.md
31
README.md
@ -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
122
SPEC.md
@ -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.
|
||||
|
||||
321
apps/api/cmd/clickclack/main.go
Normal file
321
apps/api/cmd/clickclack/main.go
Normal 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
|
||||
}
|
||||
58
apps/api/internal/config/config.go
Normal file
58
apps/api/internal/config/config.go
Normal 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
|
||||
}
|
||||
63
apps/api/internal/config/config_test.go
Normal file
63
apps/api/internal/config/config_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
33
apps/api/internal/httpapi/auth.go
Normal file
33
apps/api/internal/httpapi/auth.go
Normal 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})
|
||||
}
|
||||
296
apps/api/internal/httpapi/authz_test.go
Normal file
296
apps/api/internal/httpapi/authz_test.go
Normal 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
|
||||
}
|
||||
212
apps/api/internal/httpapi/features.go
Normal file
212
apps/api/internal/httpapi/features.go
Normal 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)
|
||||
}
|
||||
230
apps/api/internal/httpapi/github.go
Normal file
230
apps/api/internal/httpapi/github.go
Normal 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 ""
|
||||
}
|
||||
200
apps/api/internal/httpapi/github_test.go
Normal file
200
apps/api/internal/httpapi/github_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
105
apps/api/internal/httpapi/mutations.go
Normal file
105
apps/api/internal/httpapi/mutations.go
Normal 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})
|
||||
}
|
||||
128
apps/api/internal/httpapi/mutations_test.go
Normal file
128
apps/api/internal/httpapi/mutations_test.go
Normal 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
|
||||
}
|
||||
412
apps/api/internal/httpapi/server.go
Normal file
412
apps/api/internal/httpapi/server.go
Normal 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)
|
||||
}
|
||||
441
apps/api/internal/httpapi/server_test.go
Normal file
441
apps/api/internal/httpapi/server_test.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
49
apps/api/internal/realtime/hub.go
Normal file
49
apps/api/internal/realtime/hub.go
Normal 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)
|
||||
}
|
||||
}
|
||||
38
apps/api/internal/realtime/hub_test.go
Normal file
38
apps/api/internal/realtime/hub_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
135
apps/api/internal/store/sqlite/auth.go
Normal file
135
apps/api/internal/store/sqlite/auth.go
Normal 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
|
||||
}
|
||||
472
apps/api/internal/store/sqlite/chat_test.go
Normal file
472
apps/api/internal/store/sqlite/chat_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
201
apps/api/internal/store/sqlite/dms.go
Normal file
201
apps/api/internal/store/sqlite/dms.go
Normal 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
|
||||
}
|
||||
67
apps/api/internal/store/sqlite/export.go
Normal file
67
apps/api/internal/store/sqlite/export.go
Normal 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()
|
||||
}
|
||||
335
apps/api/internal/store/sqlite/fault_test.go
Normal file
335
apps/api/internal/store/sqlite/fault_test.go
Normal 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]
|
||||
}
|
||||
198
apps/api/internal/store/sqlite/helpers.go
Normal file
198
apps/api/internal/store/sqlite/helpers.go
Normal 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, "-")
|
||||
}
|
||||
56
apps/api/internal/store/sqlite/identity.go
Normal file
56
apps/api/internal/store/sqlite/identity.go
Normal 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()
|
||||
}
|
||||
24
apps/api/internal/store/sqlite/invites.go
Normal file
24
apps/api/internal/store/sqlite/invites.go
Normal 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
|
||||
}
|
||||
13
apps/api/internal/store/sqlite/members.go
Normal file
13
apps/api/internal/store/sqlite/members.go
Normal 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
|
||||
}
|
||||
150
apps/api/internal/store/sqlite/migrations/0001_initial.sql
Normal file
150
apps/api/internal/store/sqlite/migrations/0001_initial.sql
Normal 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
|
||||
);
|
||||
22
apps/api/internal/store/sqlite/migrations/0002_auth.sql
Normal file
22
apps/api/internal/store/sqlite/migrations/0002_auth.sql
Normal 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);
|
||||
156
apps/api/internal/store/sqlite/misc_test.go
Normal file
156
apps/api/internal/store/sqlite/misc_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
120
apps/api/internal/store/sqlite/mutations.go
Normal file
120
apps/api/internal/store/sqlite/mutations.go
Normal 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
|
||||
}
|
||||
182
apps/api/internal/store/sqlite/mutations_test.go
Normal file
182
apps/api/internal/store/sqlite/mutations_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
75
apps/api/internal/store/sqlite/search.go
Normal file
75
apps/api/internal/store/sqlite/search.go
Normal 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
|
||||
}
|
||||
490
apps/api/internal/store/sqlite/sqlite.go
Normal file
490
apps/api/internal/store/sqlite/sqlite.go
Normal 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
|
||||
}
|
||||
232
apps/api/internal/store/sqlite/sqlite_test.go
Normal file
232
apps/api/internal/store/sqlite/sqlite_test.go
Normal 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
|
||||
}
|
||||
106
apps/api/internal/store/sqlite/uploads.go
Normal file
106
apps/api/internal/store/sqlite/uploads.go
Normal 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
|
||||
}
|
||||
243
apps/api/internal/store/types.go
Normal file
243
apps/api/internal/store/types.go
Normal 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)
|
||||
}
|
||||
61
apps/api/internal/webassets/dist/assets/index-4rv34_La.js
vendored
Normal file
61
apps/api/internal/webassets/dist/assets/index-4rv34_La.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/api/internal/webassets/dist/assets/index-Du33dVG9.css
vendored
Normal file
1
apps/api/internal/webassets/dist/assets/index-Du33dVG9.css
vendored
Normal file
File diff suppressed because one or more lines are too long
13
apps/api/internal/webassets/dist/index.html
vendored
Normal file
13
apps/api/internal/webassets/dist/index.html
vendored
Normal 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>
|
||||
8
apps/api/internal/webassets/webassets.go
Normal file
8
apps/api/internal/webassets/webassets.go
Normal 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
12
apps/web/index.html
Normal 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
23
apps/web/package.json
Normal 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
472
apps/web/src/App.svelte
Normal 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
11
apps/web/src/lib/api.ts
Normal 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>;
|
||||
}
|
||||
12
apps/web/src/lib/format.ts
Normal file
12
apps/web/src/lib/format.ts
Normal 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
83
apps/web/src/lib/types.ts
Normal 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
9
apps/web/src/main.ts
Normal 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
490
apps/web/src/styles.css
Normal 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
1
apps/web/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
13
apps/web/tsconfig.json
Normal file
13
apps/web/tsconfig.json
Normal 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
11
apps/web/vite.config.ts
Normal 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
23
docs/api/overview.md
Normal 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`.
|
||||
29
docs/architecture/overview.md
Normal file
29
docs/architecture/overview.md
Normal 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
13
examples/bot-ts/README.md
Normal 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.
|
||||
18
examples/bot-ts/package.json
Normal file
18
examples/bot-ts/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
20
examples/bot-ts/src/index.ts
Normal file
20
examples/bot-ts/src/index.ts
Normal 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;
|
||||
}
|
||||
13
examples/bot-ts/tsconfig.json
Normal file
13
examples/bot-ts/tsconfig.json
Normal 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
26
go.mod
Normal 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
57
go.sum
Normal 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=
|
||||
150
infra/migrations/sqlite/0001_initial.sql
Normal file
150
infra/migrations/sqlite/0001_initial.sql
Normal 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
|
||||
);
|
||||
22
infra/migrations/sqlite/0002_auth.sql
Normal file
22
infra/migrations/sqlite/0002_auth.sql
Normal 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
25
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
542
packages/protocol/openapi.yaml
Normal file
542
packages/protocol/openapi.yaml
Normal 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
|
||||
15
packages/protocol/package.json
Normal file
15
packages/protocol/package.json
Normal 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"
|
||||
]
|
||||
}
|
||||
16
packages/sdk-ts/package.json
Normal file
16
packages/sdk-ts/package.json
Normal 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
1214
packages/sdk-ts/src/generated/openapi.d.ts
vendored
Normal file
File diff suppressed because it is too large
Load Diff
323
packages/sdk-ts/src/index.ts
Normal file
323
packages/sdk-ts/src/index.ts
Normal 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>;
|
||||
}
|
||||
}
|
||||
13
packages/sdk-ts/tsconfig.json
Normal file
13
packages/sdk-ts/tsconfig.json
Normal 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
26
playwright.config.ts
Normal 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
1718
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
6
pnpm-workspace.yaml
Normal file
6
pnpm-workspace.yaml
Normal file
@ -0,0 +1,6 @@
|
||||
packages:
|
||||
- apps/*
|
||||
- examples/*
|
||||
- packages/*
|
||||
allowBuilds:
|
||||
esbuild: true
|
||||
75
tests/e2e/chat.spec.ts
Normal file
75
tests/e2e/chat.spec.ts
Normal 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
10
tsconfig.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"target": "ES2022",
|
||||
"types": ["node"]
|
||||
},
|
||||
"include": ["playwright.config.ts", "tests/**/*.ts"]
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user