pre mvp #1
No files matched your search
+9
-3
@@ -1,4 +1,10 @@
|
||||
POSTGRES_USER=pllist
|
||||
POSTGRES_PASSWORD=pllist
|
||||
POSTGRES_DB=pllist
|
||||
POSTGRES_HOST=0.0.0.0
|
||||
|
||||
TELEGRAM_BOT_TOKEN=
|
||||
POSTGRES_USER=
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=
|
||||
BOT_OWNER_ID=
|
||||
AUTO_MIGRATE=true
|
||||
|
||||
BOT_OWNER_ID=DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable
|
||||
@@ -0,0 +1,15 @@
|
||||
name: ci
|
||||
on: [push, pull_request]
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.27"
|
||||
- run: test -z "$(gofmt -l .)"
|
||||
- run: go vet ./...
|
||||
- run: go test ./...
|
||||
- run: go build ./...
|
||||
@@ -5,6 +5,7 @@ Access-based Telegram bot for creating and managing lists of planned purchases.
|
||||
A user owns lists. A list contains items. Items can be added, marked done, and
|
||||
reserved by the person responsible for buying them. Items carry an urgency
|
||||
level. An owner can share a list with other users as a reader or an editor.
|
||||
Bot access itself is allowlisted: the owner runs `/allow` and `/kick`.
|
||||
The codebase stays small but extensible.
|
||||
|
||||
- Architecture: `docs/architecture.md`
|
||||
|
||||
+97
-1
@@ -1,3 +1,99 @@
|
||||
package main
|
||||
|
||||
func main() {}
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/config"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/database"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/repository"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram"
|
||||
)
|
||||
|
||||
const pendingTTL = 5 * time.Minute
|
||||
|
||||
func main() {
|
||||
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
|
||||
Level: slog.LevelInfo,
|
||||
}))
|
||||
slog.SetDefault(logger)
|
||||
if err := run(logger); err != nil {
|
||||
logger.Error("bot stopped with error", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(logger *slog.Logger) error {
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("load config: %w", err)
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
db, err := database.Open(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("get sql database: %w", err)
|
||||
}
|
||||
defer sqlDB.Close()
|
||||
|
||||
if cfg.AutoMigrate {
|
||||
if err := repository.Migrate(db); err != nil {
|
||||
return fmt.Errorf("migrate database: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
tgBot, err := telego.NewBot(
|
||||
cfg.TelegramBotToken, telego.WithAPICaller(telegram.NewCaller()),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create bot: %w", err)
|
||||
}
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
shareRepo := repository.NewShareRepository(db)
|
||||
users := service.NewUserService(userRepo)
|
||||
botAccess := service.NewBotAccessService(
|
||||
repository.NewBotAccessRepository(db), cfg.BotOwnerID, shareRepo,
|
||||
)
|
||||
listRepo := repository.NewListRepository(db)
|
||||
access := service.NewAccessService(listRepo)
|
||||
lists := service.NewListService(listRepo, access)
|
||||
itemRepo := repository.NewItemRepository(db)
|
||||
items := service.NewItemService(itemRepo, access)
|
||||
shares := service.NewShareService(shareRepo, userRepo, access, botAccess)
|
||||
pending := service.NewPendingStore(pendingTTL)
|
||||
|
||||
updates, err := tgBot.UpdatesViaLongPolling(ctx, nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start long polling: %w", err)
|
||||
}
|
||||
|
||||
bot, err := telegram.New(
|
||||
tgBot, updates, users, lists, items, shares, access, botAccess, pending, logger,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
logger.Info("bot started")
|
||||
if err := bot.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
logger.Info("bot stopped")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -5,11 +5,12 @@ services:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: dockerfile
|
||||
container_name: pllist-bot
|
||||
pull_policy: build
|
||||
restart: always
|
||||
environment:
|
||||
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
|
||||
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
|
||||
- BOT_OWNER_ID=${BOT_OWNER_ID}
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -18,7 +19,6 @@ services:
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: pllist-postgres
|
||||
restart: always
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
@@ -39,4 +39,4 @@ volumes:
|
||||
|
||||
networks:
|
||||
bot-net:
|
||||
driver: bridge
|
||||
external: true
|
||||
+10
-6
@@ -1,13 +1,17 @@
|
||||
# Build stage
|
||||
FROM golang:1.27-alpine AS builder
|
||||
WORKDIR /app
|
||||
WORKDIR /src
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o /bot cmd/bot/main.go
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/bot ./cmd/bot
|
||||
|
||||
# Run stage
|
||||
FROM alpine:latest
|
||||
WORKDIR /root/
|
||||
COPY --from=builder /bot .
|
||||
CMD ["./bot"]
|
||||
FROM alpine:3.22
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S app \
|
||||
&& adduser -S -G app -h /app app
|
||||
WORKDIR /app
|
||||
COPY --from=builder --chown=app:app /out/bot /app/bot
|
||||
USER app
|
||||
ENTRYPOINT ["/app/bot"]
|
||||
@@ -30,7 +30,7 @@ internal/domain/ entities, enums, invariants, domain errors
|
||||
internal/service/ use cases: users, lists, items, access, sessions
|
||||
internal/repository/ GORM repository implementations
|
||||
internal/database/ connection setup, pool tuning, migrations
|
||||
internal/bot/ telego client, router, handlers, keyboards, rendering
|
||||
internal/telegram/ telego routes, feature handlers, keyboards, rendering
|
||||
internal/events/ publisher interface and no-op implementation
|
||||
docs/ architecture, infrastructure, plan, notes
|
||||
```
|
||||
@@ -43,9 +43,36 @@ docs/ architecture, infrastructure, plan, notes
|
||||
| `service` | Use cases and authorization. Owns transactions. Declares repository interfaces. |
|
||||
| `repository` | GORM models and queries. Implements service interfaces. |
|
||||
| `database` | Opens the pool, sets limits, exposes migration entry point. |
|
||||
| `telegram` | Maps updates to service calls, renders views, maps errors to text. |
|
||||
| `telegram` | Routes and middleware; feature subpackages own commands, callbacks, and views. |
|
||||
| `events` | `Publisher` seam for future notifications and activity feeds. |
|
||||
|
||||
## Telegram layout
|
||||
|
||||
```text
|
||||
telegram/
|
||||
bot.go, routes.go, middleware.go lifecycle, routing, identity, bot-access gate, ordering
|
||||
shared/ sender, reply keyboard, callback codec, user context, pending text router
|
||||
menu/ /start, /help, /cancel
|
||||
access/ owner /allow and /kick commands
|
||||
list/ list index, create, rename, delete
|
||||
item/ list menu, paginated item list, item screens, prompts
|
||||
share/ owner share management, pending shares
|
||||
```
|
||||
|
||||
Features register their own `telegohandler` routes. All callback data is built
|
||||
and validated in `shared`; feature packages only wrap the typed builders.
|
||||
Free-text prompts are dispatched through
|
||||
`shared.PendingRouter`, so the root text handler does not grow with each
|
||||
feature. The `item` package owns the list detail screen because it renders item
|
||||
rows and pagination; the `list` package delegates to it after create and
|
||||
rename. The `share` package owns share management; the item list menu links to
|
||||
it for owners.
|
||||
|
||||
The root middleware gate checks bot access before any feature runs. The owner
|
||||
comes from `BOT_OWNER_ID`; everyone else must have a row in `bot_access`.
|
||||
Usernames are only stored on access grants so pending grants can be resolved,
|
||||
and are refreshed when the user contacts the bot.
|
||||
|
||||
## Layering
|
||||
|
||||
```text
|
||||
@@ -74,7 +101,9 @@ size.
|
||||
|
||||
## Domain summary
|
||||
|
||||
- `users`: one row per Telegram user, keyed by `telegram_id`.
|
||||
- `bot_access`: grants bot access by numeric Telegram ID, or by username until
|
||||
the matching user contacts the bot.
|
||||
- `users`: one row per allowed Telegram user, keyed by `telegram_id`.
|
||||
- `lists`: owned by exactly one user.
|
||||
- `list_shares`: grants a user `reader` or `editor` access to a list.
|
||||
- `items`: belong to a list, have `status` (`open`/`done`), `urgency`
|
||||
@@ -82,8 +111,11 @@ size.
|
||||
- `pending_shares`: shares granted by username before that user contacts the
|
||||
bot.
|
||||
|
||||
One `Authorize(ctx, userID, listID, action)` call is the source of truth for
|
||||
access. Handlers call it once per update.
|
||||
Bot access is checked once per update before a user row is ensured. The owner
|
||||
is always allowed. After the user row is ensured, pending shares that match the
|
||||
user's Telegram ID or username are activated. One `Authorize(ctx, userID,
|
||||
listID, action)` call remains the source of truth for list access. Revoking bot
|
||||
access also removes the user's shares; owned lists are never touched.
|
||||
|
||||
Schema, access matrix, callback format, and use-case details live in
|
||||
`docs/temp-notes.md`.
|
||||
@@ -40,7 +40,9 @@ Dokploy injects these into the bot container:
|
||||
| Variable | Required | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `TELEGRAM_BOT_TOKEN` | yes | Bot token from BotFather |
|
||||
| `BOT_OWNER_ID` | yes | Numeric Telegram user ID of the bot owner |
|
||||
| `DATABASE_URL` | yes | `postgres://user:pass@host:5432/db?sslmode=disable` |
|
||||
| `AUTO_MIGRATE` | no | Run GORM AutoMigrate at startup; default `true` |
|
||||
| `POSTGRES_USER` | compose | Database role for local Compose |
|
||||
| `POSTGRES_PASSWORD` | compose | Database password for local Compose |
|
||||
| `POSTGRES_DB` | compose | Database name for local Compose |
|
||||
@@ -71,8 +73,8 @@ Use a separate bot token for development.
|
||||
## Image
|
||||
|
||||
`dockerfile` is a multi-stage build: compile a static binary, then run it on
|
||||
Alpine. It must keep `ca-certificates` installed because the bot calls the
|
||||
Telegram API over TLS.
|
||||
Alpine as an unprivileged `app` user. It keeps `ca-certificates` installed
|
||||
because the bot calls the Telegram API over TLS.
|
||||
|
||||
```sh
|
||||
docker build -t pllist-bot .
|
||||
@@ -108,8 +110,8 @@ jobs:
|
||||
3. Add an application connected to the Gitea repository.
|
||||
4. Select the Dockerfile build type and set the build context to the repository
|
||||
root.
|
||||
5. Set `TELEGRAM_BOT_TOKEN` and `DATABASE_URL` in the application environment.
|
||||
Use the PostgreSQL service host from step 2.
|
||||
5. Set `TELEGRAM_BOT_TOKEN`, `BOT_OWNER_ID`, and `DATABASE_URL` in the
|
||||
application environment. Use the PostgreSQL service host from step 2.
|
||||
6. Set replicas to `1`. Long polling does not support multiple replicas.
|
||||
7. Deploy manually. Dokploy pulls the repository, builds the image, and starts
|
||||
the container.
|
||||
|
||||
+23
-12
@@ -11,8 +11,8 @@
|
||||
|
||||
## M1 — Users and lists
|
||||
|
||||
- Register or refresh a user on `/start`.
|
||||
- Commands: `/start`, `/help`, `/lists`, `/new`, `/cancel`, `/id`.
|
||||
- Ensure a user row on every allowed update; store only the Telegram ID.
|
||||
- Commands: `/start`, `/help`, `/lists`, `/new`, `/cancel`.
|
||||
- Create, rename, and soft-delete a list. Owner only for rename and delete.
|
||||
- List index with owned and shared lists.
|
||||
|
||||
@@ -24,16 +24,29 @@
|
||||
- Reserve and release with a conditional update.
|
||||
- List detail view with pagination and role-appropriate buttons.
|
||||
|
||||
## M3 — Sharing
|
||||
## M3 — Bot access
|
||||
|
||||
- Grant `reader` or `editor` access by Telegram username or numeric ID.
|
||||
- Pending shares for usernames that cannot be resolved yet.
|
||||
- Resolve pending shares when the user contacts the bot.
|
||||
- Require `BOT_OWNER_ID`; the bot owner always has access.
|
||||
- Every other user needs an explicit grant. Deny all updates from users without
|
||||
one, including commands and callbacks.
|
||||
- Owner commands: `/allow <username or id>` and `/kick <username or id>`.
|
||||
- Username grants stay pending until that user contacts the bot, then bind to
|
||||
their Telegram ID.
|
||||
- Show the owner commands in `/help` for the bot owner only.
|
||||
|
||||
## M4 — Sharing
|
||||
|
||||
- Grant `reader` or `editor` access by Telegram username or numeric ID to
|
||||
users who already have bot access. Unknown targets get a no-access hint.
|
||||
- Pending shares for allowed usernames that have not contacted the bot yet.
|
||||
- Resolve pending shares when the target user contacts the bot.
|
||||
- Share management view for the owner: list shares, change role, revoke.
|
||||
- Kicking a user removes their shares from other owners' lists but never
|
||||
deletes their own lists.
|
||||
|
||||
## M4 — Polish
|
||||
## M5 — Polish
|
||||
|
||||
- Callback-data codec with strict validation.
|
||||
- Versioned callback-data codec with strict validation.
|
||||
- Confirmation step for destructive actions.
|
||||
- HTML escaping helper and Telegram length limits.
|
||||
- Error mapping from domain errors to user messages.
|
||||
@@ -41,12 +54,10 @@
|
||||
|
||||
## Before production
|
||||
|
||||
- Add `ca-certificates` and a non-root user to the runtime image.
|
||||
- Add versioned SQL migrations; stop relying on GORM automigrate.
|
||||
- Add unit tests for domain transitions and the access matrix.
|
||||
- Add the Gitea Actions workflow.
|
||||
- Add PostgreSQL-backed tests for the conditional item transitions (done,
|
||||
reopen, reserve, release) in the repository.
|
||||
- Enable PostgreSQL backups in Dokploy.
|
||||
- Add `429 retry_after` handling.
|
||||
|
||||
## Later
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
TelegramBotToken string
|
||||
BotOwnerID int64
|
||||
DatabaseURL string
|
||||
AutoMigrate bool
|
||||
}
|
||||
|
||||
func Load() (Config, error) {
|
||||
if err := godotenv.Load(); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return Config{}, fmt.Errorf("load .env: %w", err)
|
||||
}
|
||||
|
||||
cfg := Config{
|
||||
TelegramBotToken: os.Getenv("TELEGRAM_BOT_TOKEN"),
|
||||
DatabaseURL: os.Getenv("DATABASE_URL"),
|
||||
AutoMigrate: true,
|
||||
}
|
||||
if value := os.Getenv("BOT_OWNER_ID"); value != "" {
|
||||
ownerID, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || ownerID <= 0 {
|
||||
return Config{}, errors.New("BOT_OWNER_ID must be a positive Telegram user ID")
|
||||
}
|
||||
cfg.BotOwnerID = ownerID
|
||||
}
|
||||
if value := os.Getenv("AUTO_MIGRATE"); value != "" {
|
||||
autoMigrate, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return Config{}, fmt.Errorf("AUTO_MIGRATE must be true or false: %w", err)
|
||||
}
|
||||
cfg.AutoMigrate = autoMigrate
|
||||
}
|
||||
if err := cfg.validate(); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func (c Config) validate() error {
|
||||
if c.TelegramBotToken == "" {
|
||||
return errors.New("TELEGRAM_BOT_TOKEN is required")
|
||||
}
|
||||
if c.DatabaseURL == "" {
|
||||
return errors.New("DATABASE_URL is required")
|
||||
}
|
||||
if c.BotOwnerID <= 0 {
|
||||
return errors.New("BOT_OWNER_ID is required")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg Config
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "valid",
|
||||
cfg: Config{TelegramBotToken: "token", BotOwnerID: 42, DatabaseURL: "postgres://db"},
|
||||
},
|
||||
{
|
||||
name: "missing token",
|
||||
cfg: Config{BotOwnerID: 42, DatabaseURL: "postgres://db"},
|
||||
wantErr: "TELEGRAM_BOT_TOKEN",
|
||||
},
|
||||
{
|
||||
name: "missing database url",
|
||||
cfg: Config{TelegramBotToken: "token", BotOwnerID: 42},
|
||||
wantErr: "DATABASE_URL",
|
||||
},
|
||||
{
|
||||
name: "missing bot owner",
|
||||
cfg: Config{TelegramBotToken: "token", DatabaseURL: "postgres://db"},
|
||||
wantErr: "BOT_OWNER_ID",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.validate()
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Fatalf("validate() error = %v, want nil", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
|
||||
t.Fatalf("validate() error = %v, want containing %q", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadAutoMigrate(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
t.Setenv("TELEGRAM_BOT_TOKEN", "token")
|
||||
t.Setenv("BOT_OWNER_ID", "42")
|
||||
t.Setenv("DATABASE_URL", "postgres://db")
|
||||
|
||||
t.Run("default true", func(t *testing.T) {
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if !cfg.AutoMigrate {
|
||||
t.Fatal("AutoMigrate = false, want true")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("explicit false", func(t *testing.T) {
|
||||
t.Setenv("AUTO_MIGRATE", "false")
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error = %v", err)
|
||||
}
|
||||
if cfg.AutoMigrate {
|
||||
t.Fatal("AutoMigrate = true, want false")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid", func(t *testing.T) {
|
||||
t.Setenv("AUTO_MIGRATE", "sometimes")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load() error = nil, want error")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid bot owner", func(t *testing.T) {
|
||||
t.Setenv("BOT_OWNER_ID", "not-a-number")
|
||||
if _, err := Load(); err == nil {
|
||||
t.Fatal("Load() error = nil, want error")
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
func Open(ctx context.Context, databaseURL string) (*gorm.DB, error) {
|
||||
db, err := gorm.Open(postgres.Open(databaseURL), &gorm.Config{
|
||||
Logger: gormLogger(),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open database: %w", err)
|
||||
}
|
||||
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get sql database: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(20)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
pingCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
if err := sqlDB.PingContext(pingCtx); err != nil {
|
||||
_ = sqlDB.Close()
|
||||
return nil, fmt.Errorf("ping database: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
// gormLogger keeps normal "not found" lookups out of the error log. Missing
|
||||
// rows are expected during access checks and stale callback handling.
|
||||
func gormLogger() logger.Interface {
|
||||
return logger.New(
|
||||
log.New(os.Stdout, "\r\n", log.LstdFlags),
|
||||
logger.Config{
|
||||
SlowThreshold: 200 * time.Millisecond,
|
||||
LogLevel: logger.Warn,
|
||||
IgnoreRecordNotFoundError: true,
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package domain
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrNotFound = errors.New("not found")
|
||||
ErrForbidden = errors.New("forbidden")
|
||||
ErrConflict = errors.New("conflict")
|
||||
ErrInvalidInput = errors.New("invalid input")
|
||||
)
|
||||
|
||||
// ValidationError carries a user-safe explanation for invalid input.
|
||||
type ValidationError struct {
|
||||
Message string
|
||||
}
|
||||
|
||||
func (e ValidationError) Error() string { return e.Message }
|
||||
|
||||
func (e ValidationError) Is(target error) bool { return target == ErrInvalidInput }
|
||||
|
||||
func NewValidationError(message string) error {
|
||||
return ValidationError{Message: message}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type ItemStatus string
|
||||
|
||||
const (
|
||||
ItemStatusOpen ItemStatus = "open"
|
||||
ItemStatusDone ItemStatus = "done"
|
||||
)
|
||||
|
||||
type Urgency string
|
||||
|
||||
const (
|
||||
UrgencyLow Urgency = "low"
|
||||
UrgencyNormal Urgency = "normal"
|
||||
UrgencyHigh Urgency = "high"
|
||||
)
|
||||
|
||||
type Item struct {
|
||||
ID int64
|
||||
ListID int64
|
||||
Title string
|
||||
Note string
|
||||
Quantity float64
|
||||
Urgency Urgency
|
||||
Status ItemStatus
|
||||
ReservedByID *int64
|
||||
ReservedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
CreatedByID int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (i Item) Reserved() bool { return i.ReservedByID != nil }
|
||||
|
||||
func (i Item) Done() bool { return i.Status == ItemStatusDone }
|
||||
@@ -0,0 +1,73 @@
|
||||
package domain
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllowed(t *testing.T) {
|
||||
roles := []Role{RoleOwner, RoleEditor, RoleReader}
|
||||
tests := []struct {
|
||||
name string
|
||||
action Action
|
||||
want map[Role]bool
|
||||
}{
|
||||
{
|
||||
name: "view list",
|
||||
action: ActionViewList,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: true},
|
||||
},
|
||||
{
|
||||
name: "edit list meta",
|
||||
action: ActionEditListMeta,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: false, RoleReader: false},
|
||||
},
|
||||
{
|
||||
name: "delete list",
|
||||
action: ActionDeleteList,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: false, RoleReader: false},
|
||||
},
|
||||
{
|
||||
name: "manage shares",
|
||||
action: ActionManageShares,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: false, RoleReader: false},
|
||||
},
|
||||
{
|
||||
name: "view items",
|
||||
action: ActionViewItems,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: true},
|
||||
},
|
||||
{
|
||||
name: "add item",
|
||||
action: ActionAddItem,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false},
|
||||
},
|
||||
{
|
||||
name: "edit item",
|
||||
action: ActionEditItem,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false},
|
||||
},
|
||||
{
|
||||
name: "delete item",
|
||||
action: ActionDeleteItem,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false},
|
||||
},
|
||||
{
|
||||
name: "change item state",
|
||||
action: ActionChangeItemState,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false},
|
||||
},
|
||||
{
|
||||
name: "reserve item",
|
||||
action: ActionReserveItem,
|
||||
want: map[Role]bool{RoleOwner: true, RoleEditor: true, RoleReader: false},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
for _, role := range roles {
|
||||
if got := Allowed(role, tt.action); got != tt.want[role] {
|
||||
t.Fatalf("Allowed(%s, %v) = %v, want %v", role, tt.action, got, tt.want[role])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type Role string
|
||||
|
||||
const (
|
||||
RoleOwner Role = "owner"
|
||||
RoleEditor Role = "editor"
|
||||
RoleReader Role = "reader"
|
||||
)
|
||||
|
||||
type Action int
|
||||
|
||||
const (
|
||||
ActionViewList Action = iota
|
||||
ActionEditListMeta
|
||||
ActionDeleteList
|
||||
ActionManageShares
|
||||
ActionViewItems
|
||||
ActionAddItem
|
||||
ActionEditItem
|
||||
ActionDeleteItem
|
||||
ActionChangeItemState
|
||||
ActionReserveItem
|
||||
)
|
||||
|
||||
type List struct {
|
||||
ID int64
|
||||
OwnerID int64
|
||||
Title string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
type Access struct {
|
||||
List List
|
||||
Role Role
|
||||
}
|
||||
|
||||
func Allowed(role Role, action Action) bool {
|
||||
switch action {
|
||||
case ActionViewList:
|
||||
return role == RoleOwner || role == RoleEditor || role == RoleReader
|
||||
case ActionEditListMeta, ActionDeleteList, ActionManageShares:
|
||||
return role == RoleOwner
|
||||
case ActionViewItems:
|
||||
return role == RoleOwner || role == RoleEditor || role == RoleReader
|
||||
case ActionAddItem, ActionEditItem, ActionDeleteItem,
|
||||
ActionChangeItemState, ActionReserveItem:
|
||||
return role == RoleOwner || role == RoleEditor
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package domain
|
||||
|
||||
// Share grants a user access to a list they do not own. A pending share has no
|
||||
// local user row yet because its Telegram identity has not contacted the bot.
|
||||
type Share struct {
|
||||
ID int64
|
||||
ListID int64
|
||||
UserID int64
|
||||
TelegramID int64
|
||||
Username string
|
||||
Role Role
|
||||
Pending bool
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
package domain
|
||||
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID int64
|
||||
TelegramID int64
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type BotAccessRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewBotAccessRepository(db *gorm.DB) *BotAccessRepository {
|
||||
return &BotAccessRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *BotAccessRepository) AllowTelegramID(ctx context.Context, telegramID int64) (bool, error) {
|
||||
model := BotAccessModel{TelegramID: &telegramID}
|
||||
result := r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "telegram_id"}},
|
||||
DoNothing: true,
|
||||
}).
|
||||
Create(&model)
|
||||
if result.Error != nil {
|
||||
return false, fmt.Errorf("allow telegram id: %w", result.Error)
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func (r *BotAccessRepository) AllowUsername(ctx context.Context, username string) (bool, error) {
|
||||
model := BotAccessModel{Username: &username}
|
||||
result := r.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "username"}},
|
||||
DoNothing: true,
|
||||
}).
|
||||
Create(&model)
|
||||
if result.Error != nil {
|
||||
return false, fmt.Errorf("allow username: %w", result.Error)
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
// TelegramIDForUsername returns the bound Telegram ID for a username grant.
|
||||
// found is true for a pending grant, where telegramID is zero.
|
||||
func (r *BotAccessRepository) TelegramIDForUsername(
|
||||
ctx context.Context, username string,
|
||||
) (int64, bool, error) {
|
||||
var model BotAccessModel
|
||||
err := r.db.WithContext(ctx).Where("username = ?", username).First(&model).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return 0, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return 0, false, fmt.Errorf("get bot access by username: %w", err)
|
||||
}
|
||||
if model.TelegramID == nil {
|
||||
return 0, true, nil
|
||||
}
|
||||
return *model.TelegramID, true, nil
|
||||
}
|
||||
|
||||
func (r *BotAccessRepository) IsAllowed(ctx context.Context, telegramID int64) (bool, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&BotAccessModel{}).
|
||||
Where("telegram_id = ?", telegramID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("check bot access: %w", err)
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// Claim binds a pending username grant to the user who just contacted the bot.
|
||||
// If the user already has an ID grant, a duplicate pending row is removed and
|
||||
// the stored username is refreshed so /kick can use the current handle.
|
||||
func (r *BotAccessRepository) Claim(ctx context.Context, telegramID int64, username string) error {
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
var byID BotAccessModel
|
||||
err := tx.Where("telegram_id = ?", telegramID).First(&byID).Error
|
||||
if err == nil {
|
||||
if err := tx.Where("username = ? AND id <> ?", username, byID.ID).
|
||||
Delete(&BotAccessModel{}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if byID.Username != nil && *byID.Username == username {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&byID).Update("username", username).Error
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
var pending BotAccessModel
|
||||
err = tx.Where("username = ?", username).First(&pending).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// A username can be reassigned. Do not transfer a grant that already
|
||||
// belongs to another Telegram ID.
|
||||
if pending.TelegramID != nil && *pending.TelegramID != telegramID {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&pending).Update("telegram_id", telegramID).Error
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("claim bot access: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *BotAccessRepository) RevokeTelegramID(ctx context.Context, telegramID int64) (bool, error) {
|
||||
result := r.db.WithContext(ctx).
|
||||
Where("telegram_id = ?", telegramID).
|
||||
Delete(&BotAccessModel{})
|
||||
if result.Error != nil {
|
||||
return false, fmt.Errorf("revoke telegram id: %w", result.Error)
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
|
||||
func (r *BotAccessRepository) RevokeUsername(ctx context.Context, username string) (bool, error) {
|
||||
result := r.db.WithContext(ctx).
|
||||
Where("username = ?", username).
|
||||
Delete(&BotAccessModel{})
|
||||
if result.Error != nil {
|
||||
return false, fmt.Errorf("revoke username: %w", result.Error)
|
||||
}
|
||||
return result.RowsAffected > 0, nil
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
const itemOrder = `CASE WHEN status = 'open' THEN 0 ELSE 1 END,
|
||||
CASE urgency WHEN 'high' THEN 0 WHEN 'normal' THEN 1 ELSE 2 END,
|
||||
created_at ASC, id ASC`
|
||||
|
||||
type ItemRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewItemRepository(db *gorm.DB) *ItemRepository {
|
||||
return &ItemRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ItemRepository) Create(
|
||||
ctx context.Context, listID, createdByID int64, title string,
|
||||
) (domain.Item, error) {
|
||||
model := ItemModel{
|
||||
ListID: listID,
|
||||
Title: title,
|
||||
Quantity: 1,
|
||||
Urgency: string(domain.UrgencyNormal),
|
||||
Status: string(domain.ItemStatusOpen),
|
||||
CreatedByID: createdByID,
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(&model).Error; err != nil {
|
||||
return domain.Item{}, fmt.Errorf("create item: %w", err)
|
||||
}
|
||||
return itemToDomain(model), nil
|
||||
}
|
||||
|
||||
func (r *ItemRepository) Get(ctx context.Context, id int64) (domain.Item, error) {
|
||||
var model ItemModel
|
||||
err := r.db.WithContext(ctx).First(&model, id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domain.Item{}, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.Item{}, fmt.Errorf("get item: %w", err)
|
||||
}
|
||||
return itemToDomain(model), nil
|
||||
}
|
||||
|
||||
func (r *ItemRepository) ListByList(
|
||||
ctx context.Context, listID int64, limit, offset int,
|
||||
) ([]domain.Item, error) {
|
||||
var models []ItemModel
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("list_id = ?", listID).
|
||||
Order(itemOrder).
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&models).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list items: %w", err)
|
||||
}
|
||||
|
||||
items := make([]domain.Item, len(models))
|
||||
for i, model := range models {
|
||||
items[i] = itemToDomain(model)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (r *ItemRepository) CountByList(ctx context.Context, listID int64) (int64, error) {
|
||||
var count int64
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&ItemModel{}).
|
||||
Where("list_id = ?", listID).
|
||||
Count(&count).
|
||||
Error
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count items: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (r *ItemRepository) UpdateTitle(ctx context.Context, id int64, title string) error {
|
||||
return r.update(ctx, id, map[string]any{"title": title})
|
||||
}
|
||||
|
||||
func (r *ItemRepository) UpdateNote(ctx context.Context, id int64, note string) error {
|
||||
return r.update(ctx, id, map[string]any{"note": note})
|
||||
}
|
||||
|
||||
func (r *ItemRepository) UpdateQuantity(ctx context.Context, id int64, quantity float64) error {
|
||||
return r.update(ctx, id, map[string]any{"quantity": quantity})
|
||||
}
|
||||
|
||||
func (r *ItemRepository) UpdateUrgency(ctx context.Context, id int64, urgency domain.Urgency) error {
|
||||
return r.update(ctx, id, map[string]any{"urgency": string(urgency)})
|
||||
}
|
||||
|
||||
func (r *ItemRepository) Delete(ctx context.Context, id int64) error {
|
||||
result := r.db.WithContext(ctx).Delete(&ItemModel{}, id)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("delete item: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ItemRepository) SetDone(ctx context.Context, id int64) error {
|
||||
now := time.Now()
|
||||
return r.updateConditional(
|
||||
ctx,
|
||||
"id = ? AND status = ?", map[string]any{
|
||||
"status": string(domain.ItemStatusDone),
|
||||
"completed_at": now,
|
||||
},
|
||||
id, string(domain.ItemStatusOpen),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *ItemRepository) SetOpen(ctx context.Context, id int64) error {
|
||||
return r.updateConditional(
|
||||
ctx,
|
||||
"id = ? AND status = ?", map[string]any{
|
||||
"status": string(domain.ItemStatusOpen),
|
||||
"completed_at": nil,
|
||||
},
|
||||
id, string(domain.ItemStatusDone),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *ItemRepository) Reserve(ctx context.Context, id, userID int64) error {
|
||||
now := time.Now()
|
||||
return r.updateConditional(
|
||||
ctx,
|
||||
"id = ? AND status = ? AND reserved_by_id IS NULL", map[string]any{
|
||||
"reserved_by_id": userID,
|
||||
"reserved_at": now,
|
||||
},
|
||||
id, string(domain.ItemStatusOpen),
|
||||
)
|
||||
}
|
||||
|
||||
func (r *ItemRepository) Release(ctx context.Context, id int64) error {
|
||||
return r.updateConditional(
|
||||
ctx,
|
||||
"id = ? AND reserved_by_id IS NOT NULL", map[string]any{
|
||||
"reserved_by_id": nil,
|
||||
"reserved_at": nil,
|
||||
},
|
||||
id,
|
||||
)
|
||||
}
|
||||
|
||||
func (r *ItemRepository) update(ctx context.Context, id int64, updates map[string]any) error {
|
||||
result := r.applyUpdate(ctx, "id = ?", []any{id}, updates)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("update item: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ItemRepository) updateConditional(
|
||||
ctx context.Context, condition string, updates map[string]any, args ...any,
|
||||
) error {
|
||||
result := r.applyUpdate(ctx, condition, args, updates)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("update item: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return domain.ErrConflict
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ItemRepository) applyUpdate(
|
||||
ctx context.Context, condition string, args []any, updates map[string]any,
|
||||
) *gorm.DB {
|
||||
updates["updated_at"] = time.Now()
|
||||
return r.db.WithContext(ctx).
|
||||
Model(&ItemModel{}).
|
||||
Where(condition, args...).
|
||||
Updates(updates)
|
||||
}
|
||||
|
||||
func itemToDomain(model ItemModel) domain.Item {
|
||||
return domain.Item{
|
||||
ID: model.ID,
|
||||
ListID: model.ListID,
|
||||
Title: model.Title,
|
||||
Note: model.Note,
|
||||
Quantity: model.Quantity,
|
||||
Urgency: domain.Urgency(model.Urgency),
|
||||
Status: domain.ItemStatus(model.Status),
|
||||
ReservedByID: model.ReservedByID,
|
||||
ReservedAt: model.ReservedAt,
|
||||
CompletedAt: model.CompletedAt,
|
||||
CreatedByID: model.CreatedByID,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type ListRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewListRepository(db *gorm.DB) *ListRepository {
|
||||
return &ListRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *ListRepository) Create(ctx context.Context, ownerID int64, title string) (domain.List, error) {
|
||||
model := ListModel{OwnerID: ownerID, Title: title}
|
||||
if err := r.db.WithContext(ctx).Create(&model).Error; err != nil {
|
||||
return domain.List{}, fmt.Errorf("create list: %w", err)
|
||||
}
|
||||
return listToDomain(model), nil
|
||||
}
|
||||
|
||||
func (r *ListRepository) Get(ctx context.Context, id int64) (domain.List, error) {
|
||||
var model ListModel
|
||||
err := r.db.WithContext(ctx).First(&model, id).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domain.List{}, domain.ErrNotFound
|
||||
}
|
||||
if err != nil {
|
||||
return domain.List{}, fmt.Errorf("get list: %w", err)
|
||||
}
|
||||
return listToDomain(model), nil
|
||||
}
|
||||
|
||||
func (r *ListRepository) ListForUser(ctx context.Context, userID int64) ([]domain.List, error) {
|
||||
shared := r.db.Model(&ListShareModel{}).
|
||||
Select("list_id").
|
||||
Where("user_id = ?", userID)
|
||||
|
||||
var models []ListModel
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("owner_id = ? OR id IN (?)", userID, shared).
|
||||
Order("updated_at DESC").
|
||||
Find(&models).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list lists for user: %w", err)
|
||||
}
|
||||
|
||||
lists := make([]domain.List, len(models))
|
||||
for i, model := range models {
|
||||
lists[i] = listToDomain(model)
|
||||
}
|
||||
return lists, nil
|
||||
}
|
||||
|
||||
func (r *ListRepository) Rename(ctx context.Context, id int64, title string) error {
|
||||
err := r.db.WithContext(ctx).
|
||||
Model(&ListModel{}).
|
||||
Where("id = ?", id).
|
||||
Update("title", title).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("rename list: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ListRepository) Delete(ctx context.Context, id int64) error {
|
||||
if err := r.db.WithContext(ctx).Delete(&ListModel{}, id).Error; err != nil {
|
||||
return fmt.Errorf("delete list: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ListRepository) Role(ctx context.Context, listID, userID int64) (domain.Role, bool, error) {
|
||||
var model ListShareModel
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("list_id = ? AND user_id = ?", listID, userID).
|
||||
First(&model).
|
||||
Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return "", false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", false, fmt.Errorf("get list role: %w", err)
|
||||
}
|
||||
return domain.Role(model.Role), true, nil
|
||||
}
|
||||
|
||||
func listToDomain(model ListModel) domain.List {
|
||||
return domain.List{
|
||||
ID: model.ID,
|
||||
OwnerID: model.OwnerID,
|
||||
Title: model.Title,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func Migrate(db *gorm.DB) error {
|
||||
return db.AutoMigrate(
|
||||
&UserModel{}, &ListModel{}, &ListShareModel{}, &PendingShareModel{},
|
||||
&ItemModel{}, &BotAccessModel{},
|
||||
)
|
||||
}
|
||||
|
||||
type UserModel struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
TelegramID int64 `gorm:"not null;uniqueIndex"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (UserModel) TableName() string { return "users" }
|
||||
|
||||
type ListModel struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
OwnerID int64 `gorm:"not null;index"`
|
||||
Title string `gorm:"not null;size:120"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
DeletedAt gorm.DeletedAt `gorm:"index"`
|
||||
}
|
||||
|
||||
func (ListModel) TableName() string { return "lists" }
|
||||
|
||||
type ListShareModel struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
ListID int64 `gorm:"not null;uniqueIndex:idx_list_user"`
|
||||
UserID int64 `gorm:"not null;uniqueIndex:idx_list_user"`
|
||||
Role string `gorm:"not null;size:16"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (ListShareModel) TableName() string { return "list_shares" }
|
||||
|
||||
// PendingShareModel holds a share whose target has bot access but no local
|
||||
// user row yet. Exactly one of TelegramID and Username is set.
|
||||
type PendingShareModel struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
ListID int64 `gorm:"not null;uniqueIndex:idx_pending_list_telegram,priority:1;uniqueIndex:idx_pending_list_username,priority:1"`
|
||||
TelegramID *int64 `gorm:"uniqueIndex:idx_pending_list_telegram,priority:2"`
|
||||
Username *string `gorm:"size:32;uniqueIndex:idx_pending_list_username,priority:2"`
|
||||
Role string `gorm:"not null;size:16"`
|
||||
CreatedByID int64 `gorm:"not null"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (PendingShareModel) TableName() string { return "pending_shares" }
|
||||
|
||||
type ItemModel struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
ListID int64 `gorm:"not null;index:idx_items_list_status_urgency_created,priority:1;index:idx_items_list_created,priority:1"`
|
||||
Title string `gorm:"not null;size:200"`
|
||||
Note string `gorm:"size:1000"`
|
||||
Quantity float64 `gorm:"type:numeric(12,3);not null;default:1"`
|
||||
Urgency string `gorm:"not null;size:16;default:normal;index:idx_items_list_status_urgency_created,priority:3"`
|
||||
Status string `gorm:"not null;size:16;default:open;index:idx_items_list_status_urgency_created,priority:2"`
|
||||
ReservedByID *int64 `gorm:"index"`
|
||||
ReservedAt *time.Time
|
||||
CompletedAt *time.Time
|
||||
CreatedByID int64 `gorm:"not null;index"`
|
||||
CreatedAt time.Time `gorm:"index:idx_items_list_created,priority:2"`
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (ItemModel) TableName() string { return "items" }
|
||||
|
||||
// BotAccessModel grants bot access. A username grant is pending until the
|
||||
// matching user contacts the bot; TelegramID is then filled in.
|
||||
type BotAccessModel struct {
|
||||
ID int64 `gorm:"primaryKey"`
|
||||
TelegramID *int64 `gorm:"uniqueIndex"`
|
||||
Username *string `gorm:"uniqueIndex"`
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
func (BotAccessModel) TableName() string { return "bot_access" }
|
||||
@@ -0,0 +1,298 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type ShareRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewShareRepository(db *gorm.DB) *ShareRepository {
|
||||
return &ShareRepository{db: db}
|
||||
}
|
||||
|
||||
type activeShareRow struct {
|
||||
ID int64
|
||||
UserID int64
|
||||
TelegramID int64
|
||||
Username *string
|
||||
Role string
|
||||
}
|
||||
|
||||
func (r *ShareRepository) ListByList(ctx context.Context, listID int64) ([]domain.Share, error) {
|
||||
var active []activeShareRow
|
||||
err := r.db.WithContext(ctx).
|
||||
Table("list_shares").
|
||||
Select("list_shares.id, list_shares.user_id, users.telegram_id, bot_access.username, list_shares.role").
|
||||
Joins("JOIN users ON users.id = list_shares.user_id").
|
||||
Joins("LEFT JOIN bot_access ON bot_access.telegram_id = users.telegram_id").
|
||||
Where("list_shares.list_id = ?", listID).
|
||||
Order("list_shares.created_at ASC").
|
||||
Scan(&active).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list shares: %w", err)
|
||||
}
|
||||
|
||||
var pending []PendingShareModel
|
||||
err = r.db.WithContext(ctx).
|
||||
Where("list_id = ?", listID).
|
||||
Order("created_at ASC").
|
||||
Find(&pending).
|
||||
Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list pending shares: %w", err)
|
||||
}
|
||||
|
||||
shares := make([]domain.Share, 0, len(active)+len(pending))
|
||||
for _, row := range active {
|
||||
share := domain.Share{
|
||||
ID: row.ID,
|
||||
ListID: listID,
|
||||
UserID: row.UserID,
|
||||
TelegramID: row.TelegramID,
|
||||
Role: domain.Role(row.Role),
|
||||
}
|
||||
if row.Username != nil {
|
||||
share.Username = *row.Username
|
||||
}
|
||||
shares = append(shares, share)
|
||||
}
|
||||
for _, row := range pending {
|
||||
share := domain.Share{
|
||||
ID: row.ID,
|
||||
ListID: listID,
|
||||
Role: domain.Role(row.Role),
|
||||
Pending: true,
|
||||
}
|
||||
if row.TelegramID != nil {
|
||||
share.TelegramID = *row.TelegramID
|
||||
}
|
||||
if row.Username != nil {
|
||||
share.Username = *row.Username
|
||||
}
|
||||
shares = append(shares, share)
|
||||
}
|
||||
return shares, nil
|
||||
}
|
||||
|
||||
func (r *ShareRepository) UpsertUser(
|
||||
ctx context.Context, listID, userID int64, role domain.Role,
|
||||
) error {
|
||||
model := ListShareModel{ListID: listID, UserID: userID, Role: string(role)}
|
||||
err := r.db.WithContext(ctx).
|
||||
Clauses(userConflict()).
|
||||
Create(&model).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert list share: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ShareRepository) UpsertPendingTelegramID(
|
||||
ctx context.Context, listID, createdByID, telegramID int64, role domain.Role,
|
||||
) error {
|
||||
model := PendingShareModel{
|
||||
ListID: listID,
|
||||
TelegramID: &telegramID,
|
||||
Role: string(role),
|
||||
CreatedByID: createdByID,
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Clauses(pendingConflict("telegram_id")).
|
||||
Create(&model).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert pending share by id: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ShareRepository) UpsertPendingUsername(
|
||||
ctx context.Context, listID, createdByID int64, username string, role domain.Role,
|
||||
) error {
|
||||
model := PendingShareModel{
|
||||
ListID: listID,
|
||||
Username: &username,
|
||||
Role: string(role),
|
||||
CreatedByID: createdByID,
|
||||
}
|
||||
err := r.db.WithContext(ctx).
|
||||
Clauses(pendingConflict("username")).
|
||||
Create(&model).
|
||||
Error
|
||||
if err != nil {
|
||||
return fmt.Errorf("upsert pending share by username: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func userConflict() clause.OnConflict {
|
||||
return clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "list_id"}, {Name: "user_id"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"role", "updated_at"}),
|
||||
}
|
||||
}
|
||||
|
||||
func pendingConflict(target string) clause.OnConflict {
|
||||
return clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "list_id"}, {Name: target}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"role", "updated_at"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (r *ShareRepository) SetUserRole(
|
||||
ctx context.Context, listID, shareID int64, role domain.Role,
|
||||
) error {
|
||||
return r.setRole(ctx, &ListShareModel{}, shareID, listID, role)
|
||||
}
|
||||
|
||||
func (r *ShareRepository) SetPendingRole(
|
||||
ctx context.Context, listID, shareID int64, role domain.Role,
|
||||
) error {
|
||||
return r.setRole(ctx, &PendingShareModel{}, shareID, listID, role)
|
||||
}
|
||||
|
||||
func (r *ShareRepository) setRole(
|
||||
ctx context.Context, model any, shareID, listID int64, role domain.Role,
|
||||
) error {
|
||||
result := r.db.WithContext(ctx).
|
||||
Model(model).
|
||||
Where("id = ? AND list_id = ?", shareID, listID).
|
||||
Update("role", string(role))
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("update share role: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ShareRepository) RevokeUser(ctx context.Context, listID, shareID int64) error {
|
||||
return r.revoke(ctx, &ListShareModel{}, listID, shareID)
|
||||
}
|
||||
|
||||
func (r *ShareRepository) RevokePending(ctx context.Context, listID, shareID int64) error {
|
||||
return r.revoke(ctx, &PendingShareModel{}, listID, shareID)
|
||||
}
|
||||
|
||||
func (r *ShareRepository) revoke(
|
||||
ctx context.Context, model any, listID, shareID int64,
|
||||
) error {
|
||||
result := r.db.WithContext(ctx).
|
||||
Where("id = ? AND list_id = ?", shareID, listID).
|
||||
Delete(model)
|
||||
if result.Error != nil {
|
||||
return fmt.Errorf("revoke share: %w", result.Error)
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return domain.ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResolvePending moves shares that match the user's Telegram identity into
|
||||
// list_shares. It runs after the user row is ensured on every allowed update.
|
||||
func (r *ShareRepository) ResolvePending(
|
||||
ctx context.Context, userID, telegramID int64, username string,
|
||||
) error {
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
query := tx.Where("telegram_id = ?", telegramID)
|
||||
if username != "" {
|
||||
query = query.Or("username = ?", username)
|
||||
}
|
||||
|
||||
var pending []PendingShareModel
|
||||
if err := query.Find(&pending).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(pending) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
ids := make([]int64, len(pending))
|
||||
for i, row := range pending {
|
||||
ids[i] = row.ID
|
||||
share := ListShareModel{
|
||||
ListID: row.ListID,
|
||||
UserID: userID,
|
||||
Role: row.Role,
|
||||
}
|
||||
if err := tx.Clauses(userConflict()).Create(&share).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Where("id IN ?", ids).Delete(&PendingShareModel{}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve pending shares: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveSharedAccess removes every non-owner list grant for a kicked user.
|
||||
// Owned lists are untouched because ownership lives on lists.owner_id only.
|
||||
func (r *ShareRepository) RemoveSharedAccess(
|
||||
ctx context.Context, telegramID int64, username string,
|
||||
) error {
|
||||
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
if telegramID > 0 {
|
||||
var userIDs []int64
|
||||
if err := tx.Model(&UserModel{}).
|
||||
Where("telegram_id = ?", telegramID).
|
||||
Pluck("id", &userIDs).
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(userIDs) > 0 {
|
||||
if err := tx.Where("user_id IN ?", userIDs).
|
||||
Delete(&ListShareModel{}).
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := tx.Where("telegram_id = ?", telegramID).
|
||||
Delete(&PendingShareModel{}).
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// A pending share may have been stored by username before the
|
||||
// grant was bound to this Telegram ID.
|
||||
var grant BotAccessModel
|
||||
err := tx.Where("telegram_id = ?", telegramID).First(&grant).Error
|
||||
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
if err == nil && grant.Username != nil && *grant.Username != "" {
|
||||
if err := tx.Where("username = ?", *grant.Username).
|
||||
Delete(&PendingShareModel{}).
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if username != "" {
|
||||
if err := tx.Where("username = ?", username).
|
||||
Delete(&PendingShareModel{}).
|
||||
Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("remove shared list access: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type UserRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) *UserRepository {
|
||||
return &UserRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *UserRepository) Ensure(ctx context.Context, telegramID int64) (domain.User, error) {
|
||||
model := UserModel{TelegramID: telegramID}
|
||||
err := r.db.WithContext(ctx).
|
||||
Where("telegram_id = ?", telegramID).
|
||||
FirstOrCreate(&model).
|
||||
Error
|
||||
if err != nil {
|
||||
return domain.User{}, fmt.Errorf("ensure user: %w", err)
|
||||
}
|
||||
return userToDomain(model), nil
|
||||
}
|
||||
|
||||
func (r *UserRepository) GetByTelegramID(
|
||||
ctx context.Context, telegramID int64,
|
||||
) (domain.User, bool, error) {
|
||||
var model UserModel
|
||||
err := r.db.WithContext(ctx).Where("telegram_id = ?", telegramID).First(&model).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return domain.User{}, false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return domain.User{}, false, fmt.Errorf("get user by telegram id: %w", err)
|
||||
}
|
||||
return userToDomain(model), true, nil
|
||||
}
|
||||
|
||||
func userToDomain(model UserModel) domain.User {
|
||||
return domain.User{
|
||||
ID: model.ID,
|
||||
TelegramID: model.TelegramID,
|
||||
CreatedAt: model.CreatedAt,
|
||||
UpdatedAt: model.UpdatedAt,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type accessRepository interface {
|
||||
Get(ctx context.Context, id int64) (domain.List, error)
|
||||
Role(ctx context.Context, listID, userID int64) (domain.Role, bool, error)
|
||||
}
|
||||
|
||||
type AccessService struct {
|
||||
lists accessRepository
|
||||
}
|
||||
|
||||
func NewAccessService(lists accessRepository) *AccessService {
|
||||
return &AccessService{lists: lists}
|
||||
}
|
||||
|
||||
func (s *AccessService) Authorize(
|
||||
ctx context.Context, userID, listID int64, action domain.Action,
|
||||
) (domain.Access, error) {
|
||||
list, err := s.lists.Get(ctx, listID)
|
||||
if err != nil {
|
||||
return domain.Access{}, err
|
||||
}
|
||||
|
||||
role := domain.RoleOwner
|
||||
if list.OwnerID != userID {
|
||||
role, err = s.role(ctx, listID, userID)
|
||||
if err != nil {
|
||||
return domain.Access{}, err
|
||||
}
|
||||
}
|
||||
|
||||
if !domain.Allowed(role, action) {
|
||||
return domain.Access{}, domain.ErrForbidden
|
||||
}
|
||||
return domain.Access{List: list, Role: role}, nil
|
||||
}
|
||||
|
||||
func (s *AccessService) role(ctx context.Context, listID, userID int64) (domain.Role, error) {
|
||||
role, ok, err := s.lists.Role(ctx, listID, userID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !ok {
|
||||
return "", domain.ErrNotFound
|
||||
}
|
||||
return role, nil
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type fakeAccessRepository struct {
|
||||
lists map[int64]domain.List
|
||||
roles map[[2]int64]domain.Role
|
||||
}
|
||||
|
||||
func (f *fakeAccessRepository) Get(_ context.Context, id int64) (domain.List, error) {
|
||||
list, ok := f.lists[id]
|
||||
if !ok {
|
||||
return domain.List{}, domain.ErrNotFound
|
||||
}
|
||||
return list, nil
|
||||
}
|
||||
|
||||
func (f *fakeAccessRepository) Role(_ context.Context, listID, userID int64) (domain.Role, bool, error) {
|
||||
role, ok := f.roles[[2]int64{listID, userID}]
|
||||
return role, ok, nil
|
||||
}
|
||||
|
||||
func TestAuthorize(t *testing.T) {
|
||||
const listID = int64(10)
|
||||
repo := &fakeAccessRepository{
|
||||
lists: map[int64]domain.List{
|
||||
listID: {ID: listID, OwnerID: 1, Title: "Groceries"},
|
||||
},
|
||||
roles: map[[2]int64]domain.Role{
|
||||
{listID, 2}: domain.RoleEditor,
|
||||
{listID, 3}: domain.RoleReader,
|
||||
},
|
||||
}
|
||||
access := NewAccessService(repo)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
userID int64
|
||||
listID int64
|
||||
action domain.Action
|
||||
wantErr error
|
||||
}{
|
||||
{name: "owner views", userID: 1, listID: listID, action: domain.ActionViewList},
|
||||
{name: "owner renames", userID: 1, listID: listID, action: domain.ActionEditListMeta},
|
||||
{name: "owner deletes", userID: 1, listID: listID, action: domain.ActionDeleteList},
|
||||
{name: "owner views items", userID: 1, listID: listID, action: domain.ActionViewItems},
|
||||
{name: "owner edits items", userID: 1, listID: listID, action: domain.ActionEditItem},
|
||||
{name: "owner deletes items", userID: 1, listID: listID, action: domain.ActionDeleteItem},
|
||||
{name: "owner changes item state", userID: 1, listID: listID, action: domain.ActionChangeItemState},
|
||||
{name: "owner manages shares", userID: 1, listID: listID, action: domain.ActionManageShares},
|
||||
{name: "owner adds items", userID: 1, listID: listID, action: domain.ActionAddItem},
|
||||
{name: "owner reserves", userID: 1, listID: listID, action: domain.ActionReserveItem},
|
||||
{name: "editor views", userID: 2, listID: listID, action: domain.ActionViewList},
|
||||
{name: "editor views items", userID: 2, listID: listID, action: domain.ActionViewItems},
|
||||
{name: "editor adds items", userID: 2, listID: listID, action: domain.ActionAddItem},
|
||||
{name: "editor edits items", userID: 2, listID: listID, action: domain.ActionEditItem},
|
||||
{name: "editor deletes items", userID: 2, listID: listID, action: domain.ActionDeleteItem},
|
||||
{name: "editor changes item state", userID: 2, listID: listID, action: domain.ActionChangeItemState},
|
||||
{name: "editor reserves", userID: 2, listID: listID, action: domain.ActionReserveItem},
|
||||
{name: "editor cannot rename", userID: 2, listID: listID, action: domain.ActionEditListMeta, wantErr: domain.ErrForbidden},
|
||||
{name: "editor cannot delete", userID: 2, listID: listID, action: domain.ActionDeleteList, wantErr: domain.ErrForbidden},
|
||||
{name: "editor cannot manage shares", userID: 2, listID: listID, action: domain.ActionManageShares, wantErr: domain.ErrForbidden},
|
||||
{name: "reader views", userID: 3, listID: listID, action: domain.ActionViewList},
|
||||
{name: "reader views items", userID: 3, listID: listID, action: domain.ActionViewItems},
|
||||
{name: "reader cannot add items", userID: 3, listID: listID, action: domain.ActionAddItem, wantErr: domain.ErrForbidden},
|
||||
{name: "reader cannot edit items", userID: 3, listID: listID, action: domain.ActionEditItem, wantErr: domain.ErrForbidden},
|
||||
{name: "reader cannot delete items", userID: 3, listID: listID, action: domain.ActionDeleteItem, wantErr: domain.ErrForbidden},
|
||||
{name: "reader cannot change item state", userID: 3, listID: listID, action: domain.ActionChangeItemState, wantErr: domain.ErrForbidden},
|
||||
{name: "reader cannot reserve", userID: 3, listID: listID, action: domain.ActionReserveItem, wantErr: domain.ErrForbidden},
|
||||
{name: "reader cannot rename", userID: 3, listID: listID, action: domain.ActionEditListMeta, wantErr: domain.ErrForbidden},
|
||||
{name: "reader cannot manage shares", userID: 3, listID: listID, action: domain.ActionManageShares, wantErr: domain.ErrForbidden},
|
||||
{name: "stranger gets not found", userID: 4, listID: listID, action: domain.ActionViewList, wantErr: domain.ErrNotFound},
|
||||
{name: "stranger cannot view items", userID: 4, listID: listID, action: domain.ActionViewItems, wantErr: domain.ErrNotFound},
|
||||
{name: "missing list", userID: 1, listID: 99, action: domain.ActionViewList, wantErr: domain.ErrNotFound},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := access.Authorize(context.Background(), tt.userID, tt.listID, tt.action)
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("Authorize() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
var usernamePattern = regexp.MustCompile(`^[a-z0-9_]{5,32}$`)
|
||||
|
||||
type BotAccessRepository interface {
|
||||
AllowTelegramID(ctx context.Context, telegramID int64) (bool, error)
|
||||
AllowUsername(ctx context.Context, username string) (bool, error)
|
||||
IsAllowed(ctx context.Context, telegramID int64) (bool, error)
|
||||
TelegramIDForUsername(ctx context.Context, username string) (int64, bool, error)
|
||||
Claim(ctx context.Context, telegramID int64, username string) error
|
||||
RevokeTelegramID(ctx context.Context, telegramID int64) (bool, error)
|
||||
RevokeUsername(ctx context.Context, username string) (bool, error)
|
||||
}
|
||||
|
||||
// SharedAccessCleaner removes list shares when a user loses bot access.
|
||||
type SharedAccessCleaner interface {
|
||||
RemoveSharedAccess(ctx context.Context, telegramID int64, username string) error
|
||||
}
|
||||
|
||||
type BotAccessService struct {
|
||||
access BotAccessRepository
|
||||
cleaner SharedAccessCleaner
|
||||
ownerID int64
|
||||
}
|
||||
|
||||
func NewBotAccessService(
|
||||
access BotAccessRepository, ownerID int64, cleaner SharedAccessCleaner,
|
||||
) *BotAccessService {
|
||||
return &BotAccessService{access: access, cleaner: cleaner, ownerID: ownerID}
|
||||
}
|
||||
|
||||
func (s *BotAccessService) IsOwner(telegramID int64) bool {
|
||||
return s.ownerID > 0 && telegramID == s.ownerID
|
||||
}
|
||||
|
||||
// IsAllowed reports bot access and includes the configured owner.
|
||||
func (s *BotAccessService) IsAllowed(ctx context.Context, telegramID int64) (bool, error) {
|
||||
if s.IsOwner(telegramID) {
|
||||
return true, nil
|
||||
}
|
||||
return s.access.IsAllowed(ctx, telegramID)
|
||||
}
|
||||
|
||||
// TelegramIDForUsername returns the bound Telegram ID for a grant. found is
|
||||
// true for a pending username grant, where telegramID is zero.
|
||||
func (s *BotAccessService) TelegramIDForUsername(
|
||||
ctx context.Context, username string,
|
||||
) (int64, bool, error) {
|
||||
return s.access.TelegramIDForUsername(ctx, NormalizeUsername(username))
|
||||
}
|
||||
|
||||
// EnsureAllowed claims any pending username grant and reports whether the
|
||||
// Telegram user may use the bot.
|
||||
func (s *BotAccessService) EnsureAllowed(
|
||||
ctx context.Context, telegramID int64, username string,
|
||||
) (bool, error) {
|
||||
if s.IsOwner(telegramID) {
|
||||
return true, nil
|
||||
}
|
||||
if err := s.access.Claim(ctx, telegramID, NormalizeUsername(username)); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return s.access.IsAllowed(ctx, telegramID)
|
||||
}
|
||||
|
||||
type GrantTarget struct {
|
||||
TelegramID int64
|
||||
Username string
|
||||
}
|
||||
|
||||
// Allow grants bot access to a username or numeric Telegram ID. It reports
|
||||
// whether a new grant was created; an existing or pending grant returns false.
|
||||
func (s *BotAccessService) Allow(
|
||||
ctx context.Context, ownerID int64, raw string,
|
||||
) (bool, GrantTarget, error) {
|
||||
if !s.IsOwner(ownerID) {
|
||||
return false, GrantTarget{}, domain.ErrForbidden
|
||||
}
|
||||
target, err := parseGrantTarget(raw, "Usage: /allow <username or id>.")
|
||||
if err != nil {
|
||||
return false, GrantTarget{}, err
|
||||
}
|
||||
if target.TelegramID == s.ownerID {
|
||||
return false, target, nil
|
||||
}
|
||||
if target.TelegramID > 0 {
|
||||
created, err := s.access.AllowTelegramID(ctx, target.TelegramID)
|
||||
return created, target, err
|
||||
}
|
||||
created, err := s.access.AllowUsername(ctx, target.Username)
|
||||
return created, target, err
|
||||
}
|
||||
|
||||
// Kick revokes bot access and reports whether a grant was removed.
|
||||
func (s *BotAccessService) Kick(
|
||||
ctx context.Context, ownerID int64, raw string,
|
||||
) (bool, GrantTarget, error) {
|
||||
if !s.IsOwner(ownerID) {
|
||||
return false, GrantTarget{}, domain.ErrForbidden
|
||||
}
|
||||
target, err := parseGrantTarget(raw, "Usage: /kick <username or id>.")
|
||||
if err != nil {
|
||||
return false, GrantTarget{}, err
|
||||
}
|
||||
if target.TelegramID == s.ownerID {
|
||||
return false, target, domain.NewValidationError("You cannot revoke your own access.")
|
||||
}
|
||||
if target.TelegramID > 0 {
|
||||
if err := s.removeSharedAccess(ctx, target.TelegramID, ""); err != nil {
|
||||
return false, target, err
|
||||
}
|
||||
revoked, err := s.access.RevokeTelegramID(ctx, target.TelegramID)
|
||||
return revoked, target, err
|
||||
}
|
||||
|
||||
telegramID, _, err := s.access.TelegramIDForUsername(ctx, target.Username)
|
||||
if err != nil {
|
||||
return false, target, err
|
||||
}
|
||||
if err := s.removeSharedAccess(ctx, telegramID, target.Username); err != nil {
|
||||
return false, target, err
|
||||
}
|
||||
revoked, err := s.access.RevokeUsername(ctx, target.Username)
|
||||
return revoked, target, err
|
||||
}
|
||||
|
||||
func (s *BotAccessService) removeSharedAccess(
|
||||
ctx context.Context, telegramID int64, username string,
|
||||
) error {
|
||||
if s.cleaner == nil {
|
||||
return nil
|
||||
}
|
||||
return s.cleaner.RemoveSharedAccess(ctx, telegramID, username)
|
||||
}
|
||||
|
||||
func parseGrantTarget(raw, usage string) (GrantTarget, error) {
|
||||
target := strings.TrimPrefix(strings.TrimSpace(raw), "@")
|
||||
if target == "" {
|
||||
return GrantTarget{}, domain.NewValidationError(usage)
|
||||
}
|
||||
if id, err := strconv.ParseInt(target, 10, 64); err == nil {
|
||||
if id <= 0 {
|
||||
return GrantTarget{}, domain.NewValidationError("User ID must be positive.")
|
||||
}
|
||||
return GrantTarget{TelegramID: id}, nil
|
||||
}
|
||||
|
||||
username := NormalizeUsername(target)
|
||||
if !usernamePattern.MatchString(username) {
|
||||
return GrantTarget{}, domain.NewValidationError(
|
||||
"Send a Telegram @username or a numeric user ID.",
|
||||
)
|
||||
}
|
||||
return GrantTarget{Username: username}, nil
|
||||
}
|
||||
|
||||
func NormalizeUsername(username string) string {
|
||||
return strings.ToLower(strings.TrimPrefix(strings.TrimSpace(username), "@"))
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type fakeBotAccessRepository struct {
|
||||
allowedIDs map[int64]bool
|
||||
usernameIDs map[string]int64
|
||||
claimCalls []claimCall
|
||||
allowIDCalls []int64
|
||||
allowNameCalls []string
|
||||
revokeIDCalls []int64
|
||||
revokeNameCall []string
|
||||
revokeResult bool
|
||||
}
|
||||
|
||||
type fakeSharedAccessCleaner struct {
|
||||
telegramIDs []int64
|
||||
usernames []string
|
||||
}
|
||||
|
||||
func (f *fakeSharedAccessCleaner) RemoveSharedAccess(
|
||||
_ context.Context, telegramID int64, username string,
|
||||
) error {
|
||||
f.telegramIDs = append(f.telegramIDs, telegramID)
|
||||
f.usernames = append(f.usernames, username)
|
||||
return nil
|
||||
}
|
||||
|
||||
type claimCall struct {
|
||||
telegramID int64
|
||||
username string
|
||||
}
|
||||
|
||||
func (f *fakeBotAccessRepository) AllowTelegramID(_ context.Context, telegramID int64) (bool, error) {
|
||||
f.allowIDCalls = append(f.allowIDCalls, telegramID)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAccessRepository) AllowUsername(_ context.Context, username string) (bool, error) {
|
||||
f.allowNameCalls = append(f.allowNameCalls, username)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAccessRepository) IsAllowed(_ context.Context, telegramID int64) (bool, error) {
|
||||
return f.allowedIDs[telegramID], nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAccessRepository) TelegramIDForUsername(
|
||||
_ context.Context, username string,
|
||||
) (int64, bool, error) {
|
||||
id, ok := f.usernameIDs[username]
|
||||
return id, ok, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAccessRepository) Claim(
|
||||
_ context.Context, telegramID int64, username string,
|
||||
) error {
|
||||
f.claimCalls = append(f.claimCalls, claimCall{telegramID: telegramID, username: username})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAccessRepository) RevokeTelegramID(
|
||||
_ context.Context, telegramID int64,
|
||||
) (bool, error) {
|
||||
f.revokeIDCalls = append(f.revokeIDCalls, telegramID)
|
||||
return f.revokeResult, nil
|
||||
}
|
||||
|
||||
func (f *fakeBotAccessRepository) RevokeUsername(_ context.Context, username string) (bool, error) {
|
||||
f.revokeNameCall = append(f.revokeNameCall, username)
|
||||
return f.revokeResult, nil
|
||||
}
|
||||
|
||||
func TestBotAccessEnsureAllowed(t *testing.T) {
|
||||
t.Run("owner bypasses the grant table", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{}
|
||||
access := NewBotAccessService(repo, 1, nil)
|
||||
|
||||
allowed, err := access.EnsureAllowed(context.Background(), 1, "")
|
||||
if err != nil || !allowed {
|
||||
t.Fatalf("EnsureAllowed() = %v, %v, want true, nil", allowed, err)
|
||||
}
|
||||
if len(repo.claimCalls) != 0 {
|
||||
t.Fatalf("owner made claim calls: %v", repo.claimCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("claims username before checking", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{allowedIDs: map[int64]bool{2: true}}
|
||||
access := NewBotAccessService(repo, 1, nil)
|
||||
|
||||
allowed, err := access.EnsureAllowed(context.Background(), 2, "@Alice_1")
|
||||
if err != nil || !allowed {
|
||||
t.Fatalf("EnsureAllowed() = %v, %v, want true, nil", allowed, err)
|
||||
}
|
||||
want := claimCall{telegramID: 2, username: "alice_1"}
|
||||
if len(repo.claimCalls) != 1 || repo.claimCalls[0] != want {
|
||||
t.Fatalf("claim calls = %v, want %v", repo.claimCalls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("denied user", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{}
|
||||
access := NewBotAccessService(repo, 1, nil)
|
||||
|
||||
allowed, err := access.EnsureAllowed(context.Background(), 2, "")
|
||||
if err != nil || allowed {
|
||||
t.Fatalf("EnsureAllowed() = %v, %v, want false, nil", allowed, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBotAccessAllow(t *testing.T) {
|
||||
t.Run("rejects non-owner", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{}
|
||||
access := NewBotAccessService(repo, 1, nil)
|
||||
|
||||
if _, _, err := access.Allow(context.Background(), 2, "alice_1"); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("Allow() error = %v, want ErrForbidden", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("normalizes username", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{}
|
||||
access := NewBotAccessService(repo, 1, nil)
|
||||
|
||||
created, target, err := access.Allow(context.Background(), 1, "@Alice_1")
|
||||
if err != nil || !created {
|
||||
t.Fatalf("Allow() = %v, %v, want true, nil", created, err)
|
||||
}
|
||||
if target.Username != "alice_1" {
|
||||
t.Fatalf("target username = %q, want alice_1", target.Username)
|
||||
}
|
||||
want := []string{"alice_1"}
|
||||
if len(repo.allowNameCalls) != 1 || repo.allowNameCalls[0] != want[0] {
|
||||
t.Fatalf("AllowUsername calls = %v, want %v", repo.allowNameCalls, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("numeric id", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{}
|
||||
access := NewBotAccessService(repo, 1, nil)
|
||||
|
||||
if _, target, err := access.Allow(context.Background(), 1, "12345"); err != nil {
|
||||
t.Fatalf("Allow() error = %v", err)
|
||||
} else if target.TelegramID != 12345 {
|
||||
t.Fatalf("target ID = %d, want 12345", target.TelegramID)
|
||||
}
|
||||
if len(repo.allowIDCalls) != 1 || repo.allowIDCalls[0] != 12345 {
|
||||
t.Fatalf("AllowTelegramID calls = %v, want [12345]", repo.allowIDCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("owner is already allowed", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{}
|
||||
access := NewBotAccessService(repo, 1, nil)
|
||||
|
||||
created, _, err := access.Allow(context.Background(), 1, "1")
|
||||
if err != nil || created {
|
||||
t.Fatalf("Allow() = %v, %v, want false, nil", created, err)
|
||||
}
|
||||
if len(repo.allowIDCalls) != 0 {
|
||||
t.Fatalf("owner grant was written: %v", repo.allowIDCalls)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestBotAccessKick(t *testing.T) {
|
||||
t.Run("rejects non-owner", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{}
|
||||
access := NewBotAccessService(repo, 1, nil)
|
||||
|
||||
if _, _, err := access.Kick(context.Background(), 2, "alice_1"); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("Kick() error = %v, want ErrForbidden", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("refuses to kick the owner", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{}
|
||||
access := NewBotAccessService(repo, 1, nil)
|
||||
|
||||
_, _, err := access.Kick(context.Background(), 1, "1")
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("Kick() error = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
if len(repo.revokeIDCalls) != 0 {
|
||||
t.Fatalf("owner revoke was attempted: %v", repo.revokeIDCalls)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("revokes by username and removes shared lists", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{
|
||||
revokeResult: true,
|
||||
usernameIDs: map[string]int64{"alice_1": 12345},
|
||||
}
|
||||
cleaner := &fakeSharedAccessCleaner{}
|
||||
access := NewBotAccessService(repo, 1, cleaner)
|
||||
|
||||
revoked, _, err := access.Kick(context.Background(), 1, "@Alice_1")
|
||||
if err != nil || !revoked {
|
||||
t.Fatalf("Kick() = %v, %v, want true, nil", revoked, err)
|
||||
}
|
||||
if len(repo.revokeNameCall) != 1 || repo.revokeNameCall[0] != "alice_1" {
|
||||
t.Fatalf("RevokeUsername calls = %v, want [alice_1]", repo.revokeNameCall)
|
||||
}
|
||||
if len(cleaner.telegramIDs) != 1 || cleaner.telegramIDs[0] != 12345 {
|
||||
t.Fatalf("cleaner telegram IDs = %v, want [12345]", cleaner.telegramIDs)
|
||||
}
|
||||
if len(cleaner.usernames) != 1 || cleaner.usernames[0] != "alice_1" {
|
||||
t.Fatalf("cleaner usernames = %v, want [alice_1]", cleaner.usernames)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("revokes by id and removes shared lists", func(t *testing.T) {
|
||||
repo := &fakeBotAccessRepository{revokeResult: true}
|
||||
cleaner := &fakeSharedAccessCleaner{}
|
||||
access := NewBotAccessService(repo, 1, cleaner)
|
||||
|
||||
if _, _, err := access.Kick(context.Background(), 1, "12345"); err != nil {
|
||||
t.Fatalf("Kick() error = %v", err)
|
||||
}
|
||||
if len(repo.revokeIDCalls) != 1 || repo.revokeIDCalls[0] != 12345 {
|
||||
t.Fatalf("RevokeTelegramID calls = %v, want [12345]", repo.revokeIDCalls)
|
||||
}
|
||||
if len(cleaner.telegramIDs) != 1 || cleaner.telegramIDs[0] != 12345 {
|
||||
t.Fatalf("cleaner telegram IDs = %v, want [12345]", cleaner.telegramIDs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseGrantTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
raw string
|
||||
wantID int64
|
||||
wantName string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "username with at", raw: "@Alice_1", wantName: "alice_1"},
|
||||
{name: "username without at", raw: "alice_1", wantName: "alice_1"},
|
||||
{name: "numeric id", raw: "12345", wantID: 12345},
|
||||
{name: "empty", raw: "", wantErr: true},
|
||||
{name: "zero id", raw: "0", wantErr: true},
|
||||
{name: "negative id", raw: "-5", wantErr: true},
|
||||
{name: "short username", raw: "abcd", wantErr: true},
|
||||
{name: "space in username", raw: "alice one", wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
target, err := parseGrantTarget(tt.raw, "usage")
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("parseGrantTarget() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantErr {
|
||||
return
|
||||
}
|
||||
if target.TelegramID != tt.wantID || target.Username != tt.wantName {
|
||||
t.Fatalf("parseGrantTarget() = %+v, want id %d name %q",
|
||||
target, tt.wantID, tt.wantName)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
ItemPageSize = 10
|
||||
maxItemTitleLength = 200
|
||||
maxItemNoteLength = 1000
|
||||
)
|
||||
|
||||
type ItemRepository interface {
|
||||
Create(ctx context.Context, listID, createdByID int64, title string) (domain.Item, error)
|
||||
Get(ctx context.Context, id int64) (domain.Item, error)
|
||||
ListByList(ctx context.Context, listID int64, limit, offset int) ([]domain.Item, error)
|
||||
CountByList(ctx context.Context, listID int64) (int64, error)
|
||||
UpdateTitle(ctx context.Context, id int64, title string) error
|
||||
UpdateNote(ctx context.Context, id int64, note string) error
|
||||
UpdateQuantity(ctx context.Context, id int64, quantity float64) error
|
||||
UpdateUrgency(ctx context.Context, id int64, urgency domain.Urgency) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
SetDone(ctx context.Context, id int64) error
|
||||
SetOpen(ctx context.Context, id int64) error
|
||||
Reserve(ctx context.Context, id, userID int64) error
|
||||
Release(ctx context.Context, id int64) error
|
||||
}
|
||||
|
||||
type ItemService struct {
|
||||
items ItemRepository
|
||||
access *AccessService
|
||||
}
|
||||
|
||||
func NewItemService(items ItemRepository, access *AccessService) *ItemService {
|
||||
return &ItemService{items: items, access: access}
|
||||
}
|
||||
|
||||
type ItemPage struct {
|
||||
Access domain.Access
|
||||
Items []domain.Item
|
||||
Page int
|
||||
Total int
|
||||
}
|
||||
|
||||
func (p ItemPage) PageCount() int {
|
||||
if p.Total == 0 {
|
||||
return 1
|
||||
}
|
||||
return (p.Total + ItemPageSize - 1) / ItemPageSize
|
||||
}
|
||||
|
||||
func (p ItemPage) HasPrev() bool { return p.Page > 1 }
|
||||
|
||||
func (p ItemPage) HasNext() bool { return p.Page < p.PageCount() }
|
||||
|
||||
type ItemAccess struct {
|
||||
Item domain.Item
|
||||
Access domain.Access
|
||||
}
|
||||
|
||||
func (s *ItemService) List(
|
||||
ctx context.Context, userID, listID int64, page int,
|
||||
) (ItemPage, error) {
|
||||
access, err := s.access.Authorize(ctx, userID, listID, domain.ActionViewItems)
|
||||
if err != nil {
|
||||
return ItemPage{}, err
|
||||
}
|
||||
|
||||
total, err := s.items.CountByList(ctx, listID)
|
||||
if err != nil {
|
||||
return ItemPage{}, err
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
pageCount := (int(total) + ItemPageSize - 1) / ItemPageSize
|
||||
if pageCount < 1 {
|
||||
pageCount = 1
|
||||
}
|
||||
if page > pageCount {
|
||||
page = pageCount
|
||||
}
|
||||
|
||||
items, err := s.items.ListByList(ctx, listID, ItemPageSize, (page-1)*ItemPageSize)
|
||||
if err != nil {
|
||||
return ItemPage{}, err
|
||||
}
|
||||
return ItemPage{Access: access, Items: items, Page: page, Total: int(total)}, nil
|
||||
}
|
||||
|
||||
func (s *ItemService) Get(ctx context.Context, userID, itemID int64) (ItemAccess, error) {
|
||||
return s.Authorize(ctx, userID, itemID, domain.ActionViewItems)
|
||||
}
|
||||
|
||||
func (s *ItemService) Authorize(
|
||||
ctx context.Context, userID, itemID int64, action domain.Action,
|
||||
) (ItemAccess, error) {
|
||||
item, err := s.items.Get(ctx, itemID)
|
||||
if err != nil {
|
||||
return ItemAccess{}, err
|
||||
}
|
||||
access, err := s.access.Authorize(ctx, userID, item.ListID, action)
|
||||
if err != nil {
|
||||
return ItemAccess{}, err
|
||||
}
|
||||
return ItemAccess{Item: item, Access: access}, nil
|
||||
}
|
||||
|
||||
func (s *ItemService) Create(
|
||||
ctx context.Context, userID, listID int64, title string,
|
||||
) (domain.Item, error) {
|
||||
if _, err := s.access.Authorize(ctx, userID, listID, domain.ActionAddItem); err != nil {
|
||||
return domain.Item{}, err
|
||||
}
|
||||
title, err := validateItemTitle(title)
|
||||
if err != nil {
|
||||
return domain.Item{}, err
|
||||
}
|
||||
return s.items.Create(ctx, listID, userID, title)
|
||||
}
|
||||
|
||||
func (s *ItemService) Rename(ctx context.Context, userID, itemID int64, title string) error {
|
||||
if _, err := s.Authorize(ctx, userID, itemID, domain.ActionEditItem); err != nil {
|
||||
return err
|
||||
}
|
||||
title, err := validateItemTitle(title)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.items.UpdateTitle(ctx, itemID, title)
|
||||
}
|
||||
|
||||
func (s *ItemService) SetNote(ctx context.Context, userID, itemID int64, note string) error {
|
||||
if _, err := s.Authorize(ctx, userID, itemID, domain.ActionEditItem); err != nil {
|
||||
return err
|
||||
}
|
||||
note, err := validateItemNote(note)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.items.UpdateNote(ctx, itemID, note)
|
||||
}
|
||||
|
||||
func (s *ItemService) SetQuantity(ctx context.Context, userID, itemID int64, quantity float64) error {
|
||||
if _, err := s.Authorize(ctx, userID, itemID, domain.ActionEditItem); err != nil {
|
||||
return err
|
||||
}
|
||||
quantity, err := normalizeItemQuantity(quantity)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return s.items.UpdateQuantity(ctx, itemID, quantity)
|
||||
}
|
||||
|
||||
func (s *ItemService) SetUrgency(
|
||||
ctx context.Context, userID, itemID int64, urgency domain.Urgency,
|
||||
) error {
|
||||
if _, err := s.Authorize(ctx, userID, itemID, domain.ActionEditItem); err != nil {
|
||||
return err
|
||||
}
|
||||
if !validUrgency(urgency) {
|
||||
return domain.NewValidationError("Invalid urgency.")
|
||||
}
|
||||
return s.items.UpdateUrgency(ctx, itemID, urgency)
|
||||
}
|
||||
|
||||
func (s *ItemService) Delete(ctx context.Context, userID, itemID int64) error {
|
||||
if _, err := s.Authorize(ctx, userID, itemID, domain.ActionDeleteItem); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.items.Delete(ctx, itemID)
|
||||
}
|
||||
|
||||
func (s *ItemService) SetDone(ctx context.Context, userID, itemID int64) error {
|
||||
if _, err := s.Authorize(ctx, userID, itemID, domain.ActionChangeItemState); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.items.SetDone(ctx, itemID)
|
||||
}
|
||||
|
||||
func (s *ItemService) Reopen(ctx context.Context, userID, itemID int64) error {
|
||||
if _, err := s.Authorize(ctx, userID, itemID, domain.ActionChangeItemState); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.items.SetOpen(ctx, itemID)
|
||||
}
|
||||
|
||||
func (s *ItemService) Reserve(ctx context.Context, userID, itemID int64) error {
|
||||
if _, err := s.Authorize(ctx, userID, itemID, domain.ActionReserveItem); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.items.Reserve(ctx, itemID, userID)
|
||||
}
|
||||
|
||||
func (s *ItemService) Release(ctx context.Context, userID, itemID int64) error {
|
||||
if _, err := s.Authorize(ctx, userID, itemID, domain.ActionReserveItem); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.items.Release(ctx, itemID)
|
||||
}
|
||||
|
||||
func validateItemTitle(title string) (string, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || utf8.RuneCountInString(title) > maxItemTitleLength {
|
||||
return "", domain.NewValidationError(
|
||||
fmt.Sprintf("Item title must be 1-%d characters.", maxItemTitleLength),
|
||||
)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
|
||||
func validateItemNote(note string) (string, error) {
|
||||
note = strings.TrimSpace(note)
|
||||
if utf8.RuneCountInString(note) > maxItemNoteLength {
|
||||
return "", domain.NewValidationError(
|
||||
fmt.Sprintf("Note must be at most %d characters.", maxItemNoteLength),
|
||||
)
|
||||
}
|
||||
return note, nil
|
||||
}
|
||||
|
||||
// Item quantity is stored as numeric(12,3), so keep the same precision in the
|
||||
// domain instead of letting the database round silently.
|
||||
func normalizeItemQuantity(quantity float64) (float64, error) {
|
||||
if math.IsNaN(quantity) || math.IsInf(quantity, 0) || quantity <= 0 {
|
||||
return 0, domain.NewValidationError("Quantity must be a positive number.")
|
||||
}
|
||||
|
||||
const scale = 1000
|
||||
rounded := math.Round(quantity*scale) / scale
|
||||
if math.Abs(quantity-rounded) > 1e-9 {
|
||||
return 0, domain.NewValidationError("Quantity can have at most 3 decimal places.")
|
||||
}
|
||||
return rounded, nil
|
||||
}
|
||||
|
||||
func validUrgency(urgency domain.Urgency) bool {
|
||||
switch urgency {
|
||||
case domain.UrgencyLow, domain.UrgencyNormal, domain.UrgencyHigh:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type fakeItemRepository struct {
|
||||
items []domain.Item
|
||||
total int64
|
||||
created domain.Item
|
||||
createCalls int
|
||||
lastLimit int
|
||||
lastOffset int
|
||||
lastReserveUser int64
|
||||
transition string
|
||||
transitionID int64
|
||||
transitionErr error
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) Create(
|
||||
_ context.Context, _, _ int64, _ string,
|
||||
) (domain.Item, error) {
|
||||
f.createCalls++
|
||||
return f.created, nil
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) Get(_ context.Context, id int64) (domain.Item, error) {
|
||||
for _, item := range f.items {
|
||||
if item.ID == id {
|
||||
return item, nil
|
||||
}
|
||||
}
|
||||
return domain.Item{}, domain.ErrNotFound
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) ListByList(
|
||||
_ context.Context, _ int64, limit, offset int,
|
||||
) ([]domain.Item, error) {
|
||||
f.lastLimit = limit
|
||||
f.lastOffset = offset
|
||||
return f.items, nil
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) CountByList(_ context.Context, _ int64) (int64, error) {
|
||||
return f.total, nil
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) UpdateTitle(_ context.Context, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) UpdateNote(_ context.Context, _ int64, _ string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) UpdateQuantity(_ context.Context, _ int64, _ float64) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) UpdateUrgency(
|
||||
_ context.Context, _ int64, _ domain.Urgency,
|
||||
) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) Delete(_ context.Context, _ int64) error { return nil }
|
||||
|
||||
func (f *fakeItemRepository) SetDone(_ context.Context, id int64) error {
|
||||
return f.transitionTo("done", id, 0)
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) SetOpen(_ context.Context, id int64) error {
|
||||
return f.transitionTo("reopen", id, 0)
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) Reserve(_ context.Context, id, userID int64) error {
|
||||
return f.transitionTo("reserve", id, userID)
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) Release(_ context.Context, id int64) error {
|
||||
return f.transitionTo("release", id, 0)
|
||||
}
|
||||
|
||||
func (f *fakeItemRepository) transitionTo(action string, id, userID int64) error {
|
||||
f.transition = action
|
||||
f.transitionID = id
|
||||
f.lastReserveUser = userID
|
||||
return f.transitionErr
|
||||
}
|
||||
|
||||
func newTestItemService() (*ItemService, *fakeItemRepository) {
|
||||
const listID = int64(10)
|
||||
accessRepo := &fakeAccessRepository{
|
||||
lists: map[int64]domain.List{
|
||||
listID: {ID: listID, OwnerID: 1, Title: "Groceries"},
|
||||
},
|
||||
roles: map[[2]int64]domain.Role{
|
||||
{listID, 2}: domain.RoleEditor,
|
||||
{listID, 3}: domain.RoleReader,
|
||||
},
|
||||
}
|
||||
itemRepo := &fakeItemRepository{
|
||||
items: []domain.Item{{ID: 5, ListID: listID, Title: "Milk"}},
|
||||
total: 25,
|
||||
}
|
||||
return NewItemService(itemRepo, NewAccessService(accessRepo)), itemRepo
|
||||
}
|
||||
|
||||
func TestItemServiceListClampsPage(t *testing.T) {
|
||||
items, itemRepo := newTestItemService()
|
||||
|
||||
itemPage, err := items.List(context.Background(), 3, 10, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if itemPage.Page != 1 {
|
||||
t.Fatalf("List() page = %d, want 1", itemPage.Page)
|
||||
}
|
||||
if itemRepo.lastLimit != ItemPageSize || itemRepo.lastOffset != 0 {
|
||||
t.Fatalf("List() limit/offset = %d/%d", itemRepo.lastLimit, itemRepo.lastOffset)
|
||||
}
|
||||
|
||||
itemPage, err = items.List(context.Background(), 3, 10, 99)
|
||||
if err != nil {
|
||||
t.Fatalf("List() error = %v", err)
|
||||
}
|
||||
if itemPage.Page != 3 {
|
||||
t.Fatalf("List() page = %d, want 3", itemPage.Page)
|
||||
}
|
||||
if itemRepo.lastOffset != 2*ItemPageSize {
|
||||
t.Fatalf("List() offset = %d, want %d", itemRepo.lastOffset, 2*ItemPageSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemServiceRejectsStranger(t *testing.T) {
|
||||
items, _ := newTestItemService()
|
||||
|
||||
if _, err := items.List(context.Background(), 4, 10, 1); !errors.Is(err, domain.ErrNotFound) {
|
||||
t.Fatalf("List() error = %v, want ErrNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemServiceCreateAuthorization(t *testing.T) {
|
||||
items, itemRepo := newTestItemService()
|
||||
|
||||
if _, err := items.Create(context.Background(), 3, 10, "Bread"); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("Create() error = %v, want ErrForbidden", err)
|
||||
}
|
||||
if itemRepo.createCalls != 0 {
|
||||
t.Fatalf("Create() called repository %d times", itemRepo.createCalls)
|
||||
}
|
||||
|
||||
if _, err := items.Create(context.Background(), 2, 10, "Bread"); err != nil {
|
||||
t.Fatalf("Create() error = %v", err)
|
||||
}
|
||||
if itemRepo.createCalls != 1 {
|
||||
t.Fatalf("Create() called repository %d times, want 1", itemRepo.createCalls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemServiceReserveAuthorization(t *testing.T) {
|
||||
items, itemRepo := newTestItemService()
|
||||
|
||||
if err := items.Reserve(context.Background(), 3, 5); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("Reserve() error = %v, want ErrForbidden", err)
|
||||
}
|
||||
if itemRepo.lastReserveUser != 0 {
|
||||
t.Fatal("Reserve() called repository for a reader")
|
||||
}
|
||||
|
||||
if err := items.Reserve(context.Background(), 2, 5); err != nil {
|
||||
t.Fatalf("Reserve() error = %v", err)
|
||||
}
|
||||
if itemRepo.lastReserveUser != 2 {
|
||||
t.Fatalf("Reserve() user = %d, want 2", itemRepo.lastReserveUser)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemServiceStateTransitions(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
call func(*ItemService) error
|
||||
want string
|
||||
wantUser int64
|
||||
}{
|
||||
{
|
||||
name: "owner marks done",
|
||||
call: func(s *ItemService) error { return s.SetDone(ctx, 1, 5) },
|
||||
want: "done",
|
||||
},
|
||||
{
|
||||
name: "editor reopens",
|
||||
call: func(s *ItemService) error { return s.Reopen(ctx, 2, 5) },
|
||||
want: "reopen",
|
||||
},
|
||||
{
|
||||
name: "editor reserves",
|
||||
call: func(s *ItemService) error { return s.Reserve(ctx, 2, 5) },
|
||||
want: "reserve",
|
||||
wantUser: 2,
|
||||
},
|
||||
{
|
||||
name: "editor releases",
|
||||
call: func(s *ItemService) error { return s.Release(ctx, 2, 5) },
|
||||
want: "release",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
items, repo := newTestItemService()
|
||||
if err := tt.call(items); err != nil {
|
||||
t.Fatalf("transition error = %v", err)
|
||||
}
|
||||
if repo.transition != tt.want || repo.transitionID != 5 {
|
||||
t.Fatalf("repository transition = %q/%d, want %q/5",
|
||||
repo.transition, repo.transitionID, tt.want)
|
||||
}
|
||||
if repo.lastReserveUser != tt.wantUser {
|
||||
t.Fatalf("repository user = %d, want %d", repo.lastReserveUser, tt.wantUser)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemServiceTransitionsRejectReaders(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
calls := []struct {
|
||||
name string
|
||||
call func(*ItemService) error
|
||||
}{
|
||||
{name: "done", call: func(s *ItemService) error { return s.SetDone(ctx, 3, 5) }},
|
||||
{name: "reopen", call: func(s *ItemService) error { return s.Reopen(ctx, 3, 5) }},
|
||||
{name: "reserve", call: func(s *ItemService) error { return s.Reserve(ctx, 3, 5) }},
|
||||
{name: "release", call: func(s *ItemService) error { return s.Release(ctx, 3, 5) }},
|
||||
}
|
||||
|
||||
for _, tt := range calls {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
items, repo := newTestItemService()
|
||||
if err := tt.call(items); !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("transition error = %v, want ErrForbidden", err)
|
||||
}
|
||||
if repo.transition != "" {
|
||||
t.Fatalf("repository transition = %q, want no call", repo.transition)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemServiceTransitionConflict(t *testing.T) {
|
||||
items, repo := newTestItemService()
|
||||
repo.transitionErr = domain.ErrConflict
|
||||
|
||||
if err := items.SetDone(context.Background(), 2, 5); !errors.Is(err, domain.ErrConflict) {
|
||||
t.Fatalf("SetDone() error = %v, want ErrConflict", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"math"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
func TestValidateItemTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "trims", input: " Milk ", want: "Milk"},
|
||||
{name: "empty", input: "", wantErr: true},
|
||||
{name: "spaces only", input: " ", wantErr: true},
|
||||
{name: "max length", input: strings.Repeat("a", 200), want: strings.Repeat("a", 200)},
|
||||
{name: "too long", input: strings.Repeat("a", 201), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := validateItemTitle(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateItemTitle() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("validateItemTitle() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateItemNote(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "trims", input: " 2% milk ", want: "2% milk"},
|
||||
{name: "empty", input: "", want: ""},
|
||||
{name: "max length", input: strings.Repeat("a", 1000), want: strings.Repeat("a", 1000)},
|
||||
{name: "too long", input: strings.Repeat("a", 1001), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := validateItemNote(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateItemNote() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("validateItemNote() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidUrgency(t *testing.T) {
|
||||
for _, urgency := range []domain.Urgency{
|
||||
domain.UrgencyLow, domain.UrgencyNormal, domain.UrgencyHigh,
|
||||
} {
|
||||
if !validUrgency(urgency) {
|
||||
t.Fatalf("validUrgency(%q) = false", urgency)
|
||||
}
|
||||
}
|
||||
if validUrgency("urgent") {
|
||||
t.Fatal("validUrgency() accepted an unknown value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemPage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
page ItemPage
|
||||
wantPages int
|
||||
wantPrev bool
|
||||
wantNext bool
|
||||
}{
|
||||
{
|
||||
name: "empty",
|
||||
page: ItemPage{Page: 1, Total: 0},
|
||||
wantPages: 1,
|
||||
},
|
||||
{
|
||||
name: "one full page",
|
||||
page: ItemPage{Page: 1, Total: ItemPageSize},
|
||||
wantPages: 1,
|
||||
},
|
||||
{
|
||||
name: "two pages on first",
|
||||
page: ItemPage{Page: 1, Total: ItemPageSize + 1},
|
||||
wantPages: 2,
|
||||
wantNext: true,
|
||||
},
|
||||
{
|
||||
name: "two pages on last",
|
||||
page: ItemPage{Page: 2, Total: ItemPageSize + 1},
|
||||
wantPages: 2,
|
||||
wantPrev: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.page.PageCount(); got != tt.wantPages {
|
||||
t.Fatalf("PageCount() = %d, want %d", got, tt.wantPages)
|
||||
}
|
||||
if got := tt.page.HasPrev(); got != tt.wantPrev {
|
||||
t.Fatalf("HasPrev() = %v, want %v", got, tt.wantPrev)
|
||||
}
|
||||
if got := tt.page.HasNext(); got != tt.wantNext {
|
||||
t.Fatalf("HasNext() = %v, want %v", got, tt.wantNext)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeItemQuantity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input float64
|
||||
want float64
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "whole number", input: 2, want: 2},
|
||||
{name: "fraction", input: 1.5, want: 1.5},
|
||||
{name: "three decimals", input: 1.234, want: 1.234},
|
||||
{name: "too many decimals", input: 1.2345, wantErr: true},
|
||||
{name: "zero", input: 0, wantErr: true},
|
||||
{name: "negative", input: -1, wantErr: true},
|
||||
{name: "nan", input: math.NaN(), wantErr: true},
|
||||
{name: "infinity", input: math.Inf(1), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := normalizeItemQuantity(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("normalizeItemQuantity() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if !tt.wantErr && got != tt.want {
|
||||
t.Fatalf("normalizeItemQuantity() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
const maxListTitleLength = 120
|
||||
|
||||
type ListRepository interface {
|
||||
Create(ctx context.Context, ownerID int64, title string) (domain.List, error)
|
||||
Get(ctx context.Context, id int64) (domain.List, error)
|
||||
ListForUser(ctx context.Context, userID int64) ([]domain.List, error)
|
||||
Rename(ctx context.Context, id int64, title string) error
|
||||
Delete(ctx context.Context, id int64) error
|
||||
Role(ctx context.Context, listID, userID int64) (domain.Role, bool, error)
|
||||
}
|
||||
|
||||
type ListService struct {
|
||||
lists ListRepository
|
||||
access *AccessService
|
||||
}
|
||||
|
||||
func NewListService(lists ListRepository, access *AccessService) *ListService {
|
||||
return &ListService{lists: lists, access: access}
|
||||
}
|
||||
|
||||
func (s *ListService) Create(ctx context.Context, userID int64, title string) (domain.List, error) {
|
||||
title, err := validateListTitle(title)
|
||||
if err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
return s.lists.Create(ctx, userID, title)
|
||||
}
|
||||
|
||||
func (s *ListService) Rename(ctx context.Context, userID, listID int64, title string) (domain.List, error) {
|
||||
title, err := validateListTitle(title)
|
||||
if err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
if _, err := s.access.Authorize(ctx, userID, listID, domain.ActionEditListMeta); err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
if err := s.lists.Rename(ctx, listID, title); err != nil {
|
||||
return domain.List{}, err
|
||||
}
|
||||
return s.lists.Get(ctx, listID)
|
||||
}
|
||||
|
||||
func (s *ListService) Delete(ctx context.Context, userID, listID int64) error {
|
||||
if _, err := s.access.Authorize(ctx, userID, listID, domain.ActionDeleteList); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.lists.Delete(ctx, listID)
|
||||
}
|
||||
|
||||
func (s *ListService) List(ctx context.Context, userID int64) ([]domain.List, error) {
|
||||
return s.lists.ListForUser(ctx, userID)
|
||||
}
|
||||
|
||||
func validateListTitle(title string) (string, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
if title == "" || utf8.RuneCountInString(title) > maxListTitleLength {
|
||||
return "", domain.NewValidationError(
|
||||
fmt.Sprintf("List title must be 1-%d characters.", maxListTitleLength),
|
||||
)
|
||||
}
|
||||
return title, nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateListTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "trims", input: " Groceries ", want: "Groceries"},
|
||||
{name: "empty", input: "", wantErr: true},
|
||||
{name: "spaces only", input: " ", wantErr: true},
|
||||
{name: "max length", input: strings.Repeat("a", 120), want: strings.Repeat("a", 120)},
|
||||
{name: "too long", input: strings.Repeat("a", 121), wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := validateListTitle(tt.input)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("validateListTitle() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("validateListTitle() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type PendingAction string
|
||||
|
||||
const (
|
||||
PendingCreateList PendingAction = "create_list"
|
||||
PendingRenameList PendingAction = "rename_list"
|
||||
|
||||
PendingCreateItem PendingAction = "create_item"
|
||||
PendingRenameItem PendingAction = "rename_item"
|
||||
PendingEditItemNote PendingAction = "edit_item_note"
|
||||
PendingEditItemQuantity PendingAction = "edit_item_quantity"
|
||||
|
||||
PendingShareTarget PendingAction = "share_target"
|
||||
PendingShareRole PendingAction = "share_role"
|
||||
)
|
||||
|
||||
type Pending struct {
|
||||
Action PendingAction
|
||||
ListID int64
|
||||
ItemID int64
|
||||
Target string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
type PendingStore struct {
|
||||
mu sync.Mutex
|
||||
ttl time.Duration
|
||||
items map[int64]Pending
|
||||
}
|
||||
|
||||
func NewPendingStore(ttl time.Duration) *PendingStore {
|
||||
// shortcut: expired entries stay in memory until the user writes again; add a janitor if user count grows.
|
||||
return &PendingStore{
|
||||
ttl: ttl,
|
||||
items: make(map[int64]Pending),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PendingStore) Set(telegramID int64, pending Pending) {
|
||||
pending.expiresAt = time.Now().Add(s.ttl)
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.items[telegramID] = pending
|
||||
}
|
||||
|
||||
func (s *PendingStore) Take(telegramID int64) (Pending, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
pending, ok := s.items[telegramID]
|
||||
delete(s.items, telegramID)
|
||||
if !ok || time.Now().After(pending.expiresAt) {
|
||||
return Pending{}, false
|
||||
}
|
||||
return pending, true
|
||||
}
|
||||
|
||||
func (s *PendingStore) Get(telegramID int64) (Pending, bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
pending, ok := s.items[telegramID]
|
||||
if !ok {
|
||||
return Pending{}, false
|
||||
}
|
||||
if time.Now().After(pending.expiresAt) {
|
||||
delete(s.items, telegramID)
|
||||
return Pending{}, false
|
||||
}
|
||||
return pending, true
|
||||
}
|
||||
|
||||
func (s *PendingStore) Clear(telegramID int64) bool {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
pending, ok := s.items[telegramID]
|
||||
delete(s.items, telegramID)
|
||||
return ok && !time.Now().After(pending.expiresAt)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestPendingStore(t *testing.T) {
|
||||
store := NewPendingStore(time.Minute)
|
||||
store.Set(1, Pending{Action: PendingCreateList})
|
||||
|
||||
pending, ok := store.Take(1)
|
||||
if !ok || pending.Action != PendingCreateList {
|
||||
t.Fatalf("Take() = %+v, %v", pending, ok)
|
||||
}
|
||||
if _, ok := store.Take(1); ok {
|
||||
t.Fatal("Take() returned a consumed pending value")
|
||||
}
|
||||
|
||||
store.Set(2, Pending{Action: PendingRenameList, ListID: 10})
|
||||
if !store.Clear(2) {
|
||||
t.Fatal("Clear() = false, want true")
|
||||
}
|
||||
if store.Clear(2) {
|
||||
t.Fatal("Clear() = true for an empty value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingStoreExpiry(t *testing.T) {
|
||||
store := NewPendingStore(0)
|
||||
store.Set(1, Pending{Action: PendingCreateList})
|
||||
|
||||
if _, ok := store.Take(1); ok {
|
||||
t.Fatal("Take() returned an expired pending value")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPendingStoreGetDoesNotConsume(t *testing.T) {
|
||||
store := NewPendingStore(time.Minute)
|
||||
store.Set(1, Pending{Action: PendingRenameList, ListID: 10})
|
||||
|
||||
first, ok := store.Get(1)
|
||||
if !ok || first.Action != PendingRenameList || first.ListID != 10 {
|
||||
t.Fatalf("Get() = %+v, %v", first, ok)
|
||||
}
|
||||
second, ok := store.Get(1)
|
||||
if !ok || second.Action != PendingRenameList {
|
||||
t.Fatalf("second Get() = %+v, %v", second, ok)
|
||||
}
|
||||
if _, ok := store.Take(1); !ok {
|
||||
t.Fatal("Take() after Get() returned no pending value")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type ShareRepository interface {
|
||||
ListByList(ctx context.Context, listID int64) ([]domain.Share, error)
|
||||
UpsertUser(ctx context.Context, listID, userID int64, role domain.Role) error
|
||||
UpsertPendingTelegramID(
|
||||
ctx context.Context, listID, createdByID, telegramID int64, role domain.Role,
|
||||
) error
|
||||
UpsertPendingUsername(
|
||||
ctx context.Context, listID, createdByID int64, username string, role domain.Role,
|
||||
) error
|
||||
SetUserRole(ctx context.Context, listID, shareID int64, role domain.Role) error
|
||||
SetPendingRole(ctx context.Context, listID, shareID int64, role domain.Role) error
|
||||
RevokeUser(ctx context.Context, listID, shareID int64) error
|
||||
RevokePending(ctx context.Context, listID, shareID int64) error
|
||||
ResolvePending(ctx context.Context, userID, telegramID int64, username string) error
|
||||
RemoveSharedAccess(ctx context.Context, telegramID int64, username string) error
|
||||
}
|
||||
|
||||
type UserLookup interface {
|
||||
GetByTelegramID(ctx context.Context, telegramID int64) (domain.User, bool, error)
|
||||
}
|
||||
|
||||
type ShareService struct {
|
||||
shares ShareRepository
|
||||
users UserLookup
|
||||
access *AccessService
|
||||
botAccess *BotAccessService
|
||||
}
|
||||
|
||||
func NewShareService(
|
||||
shares ShareRepository,
|
||||
users UserLookup,
|
||||
access *AccessService,
|
||||
botAccess *BotAccessService,
|
||||
) *ShareService {
|
||||
return &ShareService{
|
||||
shares: shares,
|
||||
users: users,
|
||||
access: access,
|
||||
botAccess: botAccess,
|
||||
}
|
||||
}
|
||||
|
||||
type ShareList struct {
|
||||
Access domain.Access
|
||||
Shares []domain.Share
|
||||
}
|
||||
|
||||
func (s *ShareService) List(
|
||||
ctx context.Context, userID, listID int64,
|
||||
) (ShareList, error) {
|
||||
access, err := s.access.Authorize(ctx, userID, listID, domain.ActionManageShares)
|
||||
if err != nil {
|
||||
return ShareList{}, err
|
||||
}
|
||||
shares, err := s.shares.ListByList(ctx, listID)
|
||||
if err != nil {
|
||||
return ShareList{}, err
|
||||
}
|
||||
return ShareList{Access: access, Shares: shares}, nil
|
||||
}
|
||||
|
||||
// Find loads the share list and one share so detail views re-check permissions
|
||||
// against fresh data.
|
||||
func (s *ShareService) Find(
|
||||
ctx context.Context, userID, listID, shareID int64, pending bool,
|
||||
) (ShareList, domain.Share, error) {
|
||||
list, err := s.List(ctx, userID, listID)
|
||||
if err != nil {
|
||||
return ShareList{}, domain.Share{}, err
|
||||
}
|
||||
for _, share := range list.Shares {
|
||||
if share.ID == shareID && share.Pending == pending {
|
||||
return list, share, nil
|
||||
}
|
||||
}
|
||||
return ShareList{}, domain.Share{}, domain.ErrNotFound
|
||||
}
|
||||
|
||||
// Prepare validates a share target and returns the label for the role prompt.
|
||||
func (s *ShareService) Prepare(
|
||||
ctx context.Context, owner domain.User, listID int64, raw string,
|
||||
) (string, error) {
|
||||
target, err := s.resolveTarget(ctx, owner, listID, raw)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return target.label(), nil
|
||||
}
|
||||
|
||||
func (s *ShareService) Share(
|
||||
ctx context.Context, owner domain.User, listID int64, raw string, role domain.Role,
|
||||
) error {
|
||||
if !validShareRole(role) {
|
||||
return domain.NewValidationError("Choose reader or editor access.")
|
||||
}
|
||||
target, err := s.resolveTarget(ctx, owner, listID, raw)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
switch {
|
||||
case target.userID > 0:
|
||||
return s.shares.UpsertUser(ctx, listID, target.userID, role)
|
||||
case target.telegramID > 0:
|
||||
return s.shares.UpsertPendingTelegramID(
|
||||
ctx, listID, owner.ID, target.telegramID, role,
|
||||
)
|
||||
default:
|
||||
return s.shares.UpsertPendingUsername(ctx, listID, owner.ID, target.username, role)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ShareService) SetRole(
|
||||
ctx context.Context, ownerID, listID, shareID int64, pending bool, role domain.Role,
|
||||
) error {
|
||||
if !validShareRole(role) {
|
||||
return domain.NewValidationError("Choose reader or editor access.")
|
||||
}
|
||||
if _, err := s.access.Authorize(ctx, ownerID, listID, domain.ActionManageShares); err != nil {
|
||||
return err
|
||||
}
|
||||
if pending {
|
||||
return s.shares.SetPendingRole(ctx, listID, shareID, role)
|
||||
}
|
||||
return s.shares.SetUserRole(ctx, listID, shareID, role)
|
||||
}
|
||||
|
||||
func (s *ShareService) Revoke(
|
||||
ctx context.Context, ownerID, listID, shareID int64, pending bool,
|
||||
) error {
|
||||
if _, err := s.access.Authorize(ctx, ownerID, listID, domain.ActionManageShares); err != nil {
|
||||
return err
|
||||
}
|
||||
if pending {
|
||||
return s.shares.RevokePending(ctx, listID, shareID)
|
||||
}
|
||||
return s.shares.RevokeUser(ctx, listID, shareID)
|
||||
}
|
||||
|
||||
// ResolvePending activates pending shares when the target contacts the bot.
|
||||
func (s *ShareService) ResolvePending(
|
||||
ctx context.Context, userID, telegramID int64, username string,
|
||||
) error {
|
||||
return s.shares.ResolvePending(ctx, userID, telegramID, NormalizeUsername(username))
|
||||
}
|
||||
|
||||
type resolvedShareTarget struct {
|
||||
userID int64
|
||||
telegramID int64
|
||||
username string
|
||||
}
|
||||
|
||||
func (t resolvedShareTarget) label() string {
|
||||
if t.username != "" {
|
||||
return "@" + t.username
|
||||
}
|
||||
return fmt.Sprintf("ID %d", t.telegramID)
|
||||
}
|
||||
|
||||
func (s *ShareService) resolveTarget(
|
||||
ctx context.Context, owner domain.User, listID int64, raw string,
|
||||
) (resolvedShareTarget, error) {
|
||||
access, err := s.access.Authorize(ctx, owner.ID, listID, domain.ActionManageShares)
|
||||
if err != nil {
|
||||
return resolvedShareTarget{}, err
|
||||
}
|
||||
target, err := parseGrantTarget(raw, "Send a Telegram @username or numeric user ID.")
|
||||
if err != nil {
|
||||
return resolvedShareTarget{}, err
|
||||
}
|
||||
if target.TelegramID == owner.TelegramID {
|
||||
return resolvedShareTarget{}, domain.NewValidationError(
|
||||
"You already own this list.",
|
||||
)
|
||||
}
|
||||
|
||||
if target.TelegramID > 0 {
|
||||
allowed, err := s.botAccess.IsAllowed(ctx, target.TelegramID)
|
||||
if err != nil {
|
||||
return resolvedShareTarget{}, err
|
||||
}
|
||||
if !allowed {
|
||||
return resolvedShareTarget{}, noBotAccessError()
|
||||
}
|
||||
user, found, err := s.users.GetByTelegramID(ctx, target.TelegramID)
|
||||
if err != nil {
|
||||
return resolvedShareTarget{}, err
|
||||
}
|
||||
if found {
|
||||
if user.ID == access.List.OwnerID {
|
||||
return resolvedShareTarget{}, domain.NewValidationError(
|
||||
"You already own this list.",
|
||||
)
|
||||
}
|
||||
return resolvedShareTarget{
|
||||
userID: user.ID,
|
||||
telegramID: user.TelegramID,
|
||||
}, nil
|
||||
}
|
||||
return resolvedShareTarget{telegramID: target.TelegramID}, nil
|
||||
}
|
||||
|
||||
telegramID, found, err := s.botAccess.TelegramIDForUsername(ctx, target.Username)
|
||||
if err != nil {
|
||||
return resolvedShareTarget{}, err
|
||||
}
|
||||
if !found {
|
||||
return resolvedShareTarget{}, noBotAccessError()
|
||||
}
|
||||
if telegramID == owner.TelegramID {
|
||||
return resolvedShareTarget{}, domain.NewValidationError(
|
||||
"You already own this list.",
|
||||
)
|
||||
}
|
||||
if telegramID > 0 {
|
||||
user, found, err := s.users.GetByTelegramID(ctx, telegramID)
|
||||
if err != nil {
|
||||
return resolvedShareTarget{}, err
|
||||
}
|
||||
if found {
|
||||
if user.ID == access.List.OwnerID {
|
||||
return resolvedShareTarget{}, domain.NewValidationError(
|
||||
"You already own this list.",
|
||||
)
|
||||
}
|
||||
return resolvedShareTarget{
|
||||
userID: user.ID,
|
||||
telegramID: user.TelegramID,
|
||||
username: target.Username,
|
||||
}, nil
|
||||
}
|
||||
return resolvedShareTarget{
|
||||
telegramID: telegramID,
|
||||
username: target.Username,
|
||||
}, nil
|
||||
}
|
||||
return resolvedShareTarget{username: target.Username}, nil
|
||||
}
|
||||
|
||||
func noBotAccessError() error {
|
||||
return domain.NewValidationError(
|
||||
"That user does not have bot access. Use /allow first.",
|
||||
)
|
||||
}
|
||||
|
||||
func validShareRole(role domain.Role) bool {
|
||||
return role == domain.RoleReader || role == domain.RoleEditor
|
||||
}
|
||||
@@ -0,0 +1,343 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type fakeUserLookup struct {
|
||||
users map[int64]domain.User
|
||||
}
|
||||
|
||||
func (f *fakeUserLookup) GetByTelegramID(
|
||||
_ context.Context, telegramID int64,
|
||||
) (domain.User, bool, error) {
|
||||
user, ok := f.users[telegramID]
|
||||
return user, ok, nil
|
||||
}
|
||||
|
||||
type fakeShareRepository struct {
|
||||
shares []domain.Share
|
||||
|
||||
userUpserts []shareUserUpsert
|
||||
pendingIDUpserts []sharePendingIDUpsert
|
||||
pendingUsernameUpserts []sharePendingUsernameUpsert
|
||||
setUserRoles []shareRoleUpdate
|
||||
setPendingRoles []shareRoleUpdate
|
||||
revokedUserShares []shareRef
|
||||
revokedPendingShares []shareRef
|
||||
resolveCalls []resolveCall
|
||||
}
|
||||
|
||||
type shareUserUpsert struct {
|
||||
listID int64
|
||||
userID int64
|
||||
role domain.Role
|
||||
}
|
||||
|
||||
type sharePendingIDUpsert struct {
|
||||
listID int64
|
||||
createdBy int64
|
||||
telegramID int64
|
||||
role domain.Role
|
||||
}
|
||||
|
||||
type sharePendingUsernameUpsert struct {
|
||||
listID int64
|
||||
createdBy int64
|
||||
username string
|
||||
role domain.Role
|
||||
}
|
||||
|
||||
type shareRoleUpdate struct {
|
||||
listID int64
|
||||
shareID int64
|
||||
role domain.Role
|
||||
}
|
||||
|
||||
type shareRef struct {
|
||||
listID int64
|
||||
shareID int64
|
||||
}
|
||||
|
||||
type resolveCall struct {
|
||||
userID int64
|
||||
telegramID int64
|
||||
username string
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) ListByList(
|
||||
_ context.Context, _ int64,
|
||||
) ([]domain.Share, error) {
|
||||
return f.shares, nil
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) UpsertUser(
|
||||
_ context.Context, listID, userID int64, role domain.Role,
|
||||
) error {
|
||||
f.userUpserts = append(f.userUpserts, shareUserUpsert{listID, userID, role})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) UpsertPendingTelegramID(
|
||||
_ context.Context, listID, createdByID, telegramID int64, role domain.Role,
|
||||
) error {
|
||||
f.pendingIDUpserts = append(f.pendingIDUpserts,
|
||||
sharePendingIDUpsert{listID, createdByID, telegramID, role})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) UpsertPendingUsername(
|
||||
_ context.Context, listID, createdByID int64, username string, role domain.Role,
|
||||
) error {
|
||||
f.pendingUsernameUpserts = append(f.pendingUsernameUpserts,
|
||||
sharePendingUsernameUpsert{listID, createdByID, username, role})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) SetUserRole(
|
||||
_ context.Context, listID, shareID int64, role domain.Role,
|
||||
) error {
|
||||
f.setUserRoles = append(f.setUserRoles, shareRoleUpdate{listID, shareID, role})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) SetPendingRole(
|
||||
_ context.Context, listID, shareID int64, role domain.Role,
|
||||
) error {
|
||||
f.setPendingRoles = append(f.setPendingRoles, shareRoleUpdate{listID, shareID, role})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) RevokeUser(_ context.Context, listID, shareID int64) error {
|
||||
f.revokedUserShares = append(f.revokedUserShares, shareRef{listID, shareID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) RevokePending(_ context.Context, listID, shareID int64) error {
|
||||
f.revokedPendingShares = append(f.revokedPendingShares, shareRef{listID, shareID})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) ResolvePending(
|
||||
_ context.Context, userID, telegramID int64, username string,
|
||||
) error {
|
||||
f.resolveCalls = append(f.resolveCalls, resolveCall{userID, telegramID, username})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeShareRepository) RemoveSharedAccess(
|
||||
_ context.Context, _ int64, _ string,
|
||||
) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func newTestShareService(
|
||||
shares *fakeShareRepository,
|
||||
users *fakeUserLookup,
|
||||
bot *fakeBotAccessRepository,
|
||||
lists *fakeAccessRepository,
|
||||
) *ShareService {
|
||||
return NewShareService(
|
||||
shares, users, NewAccessService(lists),
|
||||
NewBotAccessService(bot, 100, nil),
|
||||
)
|
||||
}
|
||||
|
||||
func TestShareServiceShare(t *testing.T) {
|
||||
owner := domain.User{ID: 1, TelegramID: 100}
|
||||
lists := &fakeAccessRepository{
|
||||
lists: map[int64]domain.List{
|
||||
10: {ID: 10, OwnerID: 1, Title: "Groceries"},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("resolved user by numeric id", func(t *testing.T) {
|
||||
shares := &fakeShareRepository{}
|
||||
users := &fakeUserLookup{users: map[int64]domain.User{
|
||||
200: {ID: 2, TelegramID: 200},
|
||||
}}
|
||||
bot := &fakeBotAccessRepository{allowedIDs: map[int64]bool{200: true}}
|
||||
service := newTestShareService(shares, users, bot, lists)
|
||||
|
||||
err := service.Share(context.Background(), owner, 10, "200", domain.RoleEditor)
|
||||
if err != nil {
|
||||
t.Fatalf("Share() error = %v", err)
|
||||
}
|
||||
want := shareUserUpsert{listID: 10, userID: 2, role: domain.RoleEditor}
|
||||
if len(shares.userUpserts) != 1 || shares.userUpserts[0] != want {
|
||||
t.Fatalf("user upserts = %v, want %v", shares.userUpserts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pending username", func(t *testing.T) {
|
||||
shares := &fakeShareRepository{}
|
||||
bot := &fakeBotAccessRepository{usernameIDs: map[string]int64{"alice_1": 0}}
|
||||
service := newTestShareService(shares, &fakeUserLookup{}, bot, lists)
|
||||
|
||||
err := service.Share(context.Background(), owner, 10, "@Alice_1", domain.RoleReader)
|
||||
if err != nil {
|
||||
t.Fatalf("Share() error = %v", err)
|
||||
}
|
||||
want := sharePendingUsernameUpsert{
|
||||
listID: 10, createdBy: 1, username: "alice_1", role: domain.RoleReader,
|
||||
}
|
||||
if len(shares.pendingUsernameUpserts) != 1 ||
|
||||
shares.pendingUsernameUpserts[0] != want {
|
||||
t.Fatalf("pending username upserts = %v, want %v",
|
||||
shares.pendingUsernameUpserts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects user without bot access", func(t *testing.T) {
|
||||
service := newTestShareService(
|
||||
&fakeShareRepository{}, &fakeUserLookup{}, &fakeBotAccessRepository{}, lists,
|
||||
)
|
||||
|
||||
err := service.Share(context.Background(), owner, 10, "@alice_1", domain.RoleEditor)
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("Share() error = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects sharing with the owner", func(t *testing.T) {
|
||||
bot := &fakeBotAccessRepository{allowedIDs: map[int64]bool{100: true}}
|
||||
users := &fakeUserLookup{users: map[int64]domain.User{
|
||||
100: {ID: 1, TelegramID: 100},
|
||||
}}
|
||||
service := newTestShareService(&fakeShareRepository{}, users, bot, lists)
|
||||
|
||||
err := service.Share(context.Background(), owner, 10, "100", domain.RoleEditor)
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("Share() error = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects non-owner", func(t *testing.T) {
|
||||
readerLists := &fakeAccessRepository{
|
||||
lists: map[int64]domain.List{
|
||||
10: {ID: 10, OwnerID: 1, Title: "Groceries"},
|
||||
},
|
||||
roles: map[[2]int64]domain.Role{{10, 2}: domain.RoleReader},
|
||||
}
|
||||
service := newTestShareService(
|
||||
&fakeShareRepository{}, &fakeUserLookup{}, &fakeBotAccessRepository{}, readerLists,
|
||||
)
|
||||
|
||||
err := service.Share(context.Background(),
|
||||
domain.User{ID: 2, TelegramID: 200}, 10, "300", domain.RoleEditor)
|
||||
if !errors.Is(err, domain.ErrForbidden) {
|
||||
t.Fatalf("Share() error = %v, want ErrForbidden", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid role", func(t *testing.T) {
|
||||
service := newTestShareService(
|
||||
&fakeShareRepository{}, &fakeUserLookup{}, &fakeBotAccessRepository{}, lists,
|
||||
)
|
||||
|
||||
err := service.Share(context.Background(), owner, 10, "200", domain.RoleOwner)
|
||||
if !errors.Is(err, domain.ErrInvalidInput) {
|
||||
t.Fatalf("Share() error = %v, want ErrInvalidInput", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShareServicePendingTargets(t *testing.T) {
|
||||
owner := domain.User{ID: 1, TelegramID: 100}
|
||||
lists := &fakeAccessRepository{
|
||||
lists: map[int64]domain.List{
|
||||
10: {ID: 10, OwnerID: 1, Title: "Groceries"},
|
||||
},
|
||||
}
|
||||
|
||||
t.Run("pending telegram id", func(t *testing.T) {
|
||||
shares := &fakeShareRepository{}
|
||||
bot := &fakeBotAccessRepository{allowedIDs: map[int64]bool{200: true}}
|
||||
service := newTestShareService(shares, &fakeUserLookup{}, bot, lists)
|
||||
|
||||
err := service.Share(context.Background(), owner, 10, "200", domain.RoleReader)
|
||||
if err != nil {
|
||||
t.Fatalf("Share() error = %v", err)
|
||||
}
|
||||
want := sharePendingIDUpsert{
|
||||
listID: 10, createdBy: 1, telegramID: 200, role: domain.RoleReader,
|
||||
}
|
||||
if len(shares.pendingIDUpserts) != 1 || shares.pendingIDUpserts[0] != want {
|
||||
t.Fatalf("pending id upserts = %v, want %v", shares.pendingIDUpserts, want)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pending username resolves to a known id", func(t *testing.T) {
|
||||
shares := &fakeShareRepository{}
|
||||
bot := &fakeBotAccessRepository{usernameIDs: map[string]int64{"alice_1": 200}}
|
||||
service := newTestShareService(shares, &fakeUserLookup{}, bot, lists)
|
||||
|
||||
if _, err := service.Prepare(context.Background(), owner, 10, "alice_1"); err != nil {
|
||||
t.Fatalf("Prepare() error = %v", err)
|
||||
}
|
||||
err := service.Share(context.Background(), owner, 10, "alice_1", domain.RoleEditor)
|
||||
if err != nil {
|
||||
t.Fatalf("Share() error = %v", err)
|
||||
}
|
||||
want := sharePendingIDUpsert{
|
||||
listID: 10, createdBy: 1, telegramID: 200, role: domain.RoleEditor,
|
||||
}
|
||||
if len(shares.pendingIDUpserts) != 1 || shares.pendingIDUpserts[0] != want {
|
||||
t.Fatalf("pending id upserts = %v, want %v", shares.pendingIDUpserts, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestShareServiceSetRoleAndRevoke(t *testing.T) {
|
||||
lists := &fakeAccessRepository{
|
||||
lists: map[int64]domain.List{
|
||||
10: {ID: 10, OwnerID: 1, Title: "Groceries"},
|
||||
},
|
||||
}
|
||||
shares := &fakeShareRepository{}
|
||||
service := newTestShareService(shares, &fakeUserLookup{}, &fakeBotAccessRepository{}, lists)
|
||||
|
||||
if err := service.SetRole(context.Background(), 1, 10, 55, false, domain.RoleReader); err != nil {
|
||||
t.Fatalf("SetRole() error = %v", err)
|
||||
}
|
||||
if len(shares.setUserRoles) != 1 ||
|
||||
shares.setUserRoles[0] != (shareRoleUpdate{10, 55, domain.RoleReader}) {
|
||||
t.Fatalf("set user roles = %v", shares.setUserRoles)
|
||||
}
|
||||
|
||||
if err := service.SetRole(context.Background(), 1, 10, 56, true, domain.RoleEditor); err != nil {
|
||||
t.Fatalf("SetRole() error = %v", err)
|
||||
}
|
||||
if len(shares.setPendingRoles) != 1 ||
|
||||
shares.setPendingRoles[0] != (shareRoleUpdate{10, 56, domain.RoleEditor}) {
|
||||
t.Fatalf("set pending roles = %v", shares.setPendingRoles)
|
||||
}
|
||||
|
||||
if err := service.Revoke(context.Background(), 1, 10, 57, false); err != nil {
|
||||
t.Fatalf("Revoke() error = %v", err)
|
||||
}
|
||||
if len(shares.revokedUserShares) != 1 ||
|
||||
shares.revokedUserShares[0] != (shareRef{10, 57}) {
|
||||
t.Fatalf("revoked user shares = %v", shares.revokedUserShares)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareServiceResolvePending(t *testing.T) {
|
||||
shares := &fakeShareRepository{}
|
||||
service := newTestShareService(
|
||||
shares, &fakeUserLookup{}, &fakeBotAccessRepository{},
|
||||
&fakeAccessRepository{},
|
||||
)
|
||||
|
||||
if err := service.ResolvePending(context.Background(), 2, 200, "@Alice_1"); err != nil {
|
||||
t.Fatalf("ResolvePending() error = %v", err)
|
||||
}
|
||||
want := resolveCall{userID: 2, telegramID: 200, username: "alice_1"}
|
||||
if len(shares.resolveCalls) != 1 || shares.resolveCalls[0] != want {
|
||||
t.Fatalf("resolve calls = %v, want %v", shares.resolveCalls, want)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Ensure(ctx context.Context, telegramID int64) (domain.User, error)
|
||||
}
|
||||
|
||||
type UserService struct {
|
||||
users UserRepository
|
||||
}
|
||||
|
||||
func NewUserService(users UserRepository) *UserService {
|
||||
return &UserService{users: users}
|
||||
}
|
||||
|
||||
func (s *UserService) Ensure(ctx context.Context, telegramID int64) (domain.User, error) {
|
||||
return s.users.Ensure(ctx, telegramID)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
sender *shared.Sender
|
||||
access *service.BotAccessService
|
||||
}
|
||||
|
||||
func New(sender *shared.Sender, access *service.BotAccessService) *Handler {
|
||||
return &Handler{sender: sender, access: access}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router *th.BotHandler) {
|
||||
router.HandleMessage(h.allow, th.CommandEqual("allow"))
|
||||
router.HandleMessage(h.kick, th.CommandEqual("kick"))
|
||||
}
|
||||
|
||||
func (h *Handler) allow(ctx *th.Context, message telego.Message) error {
|
||||
user := shared.UserFrom(ctx)
|
||||
if !h.access.IsOwner(user.TelegramID) {
|
||||
return h.sender.Send(ctx, message.Chat.ID, ownerOnlyText, nil)
|
||||
}
|
||||
|
||||
created, target, err := h.access.Allow(ctx, user.TelegramID, commandArgument(message.Text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
label := shared.Escape(targetLabel(target))
|
||||
|
||||
var text string
|
||||
switch {
|
||||
case created && target.Username != "":
|
||||
text = fmt.Sprintf(
|
||||
"Access granted to %s. It activates when they contact the bot.", label,
|
||||
)
|
||||
case created:
|
||||
text = fmt.Sprintf("Access granted to %s.", label)
|
||||
case h.access.IsOwner(target.TelegramID):
|
||||
text = "You already have access."
|
||||
default:
|
||||
text = fmt.Sprintf("%s already has access or a pending grant.", label)
|
||||
}
|
||||
return h.sender.Send(ctx, message.Chat.ID, text, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) kick(ctx *th.Context, message telego.Message) error {
|
||||
user := shared.UserFrom(ctx)
|
||||
if !h.access.IsOwner(user.TelegramID) {
|
||||
return h.sender.Send(ctx, message.Chat.ID, ownerOnlyText, nil)
|
||||
}
|
||||
|
||||
revoked, target, err := h.access.Kick(ctx, user.TelegramID, commandArgument(message.Text))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
label := shared.Escape(targetLabel(target))
|
||||
if !revoked {
|
||||
return h.sender.Send(ctx, message.Chat.ID,
|
||||
fmt.Sprintf("No access grant found for %s.", label), nil)
|
||||
}
|
||||
return h.sender.Send(ctx, message.Chat.ID,
|
||||
fmt.Sprintf("Access revoked for %s.", label), nil)
|
||||
}
|
||||
|
||||
func commandArgument(text string) string {
|
||||
fields := strings.Fields(text)
|
||||
if len(fields) < 2 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(fields[1:], " ")
|
||||
}
|
||||
|
||||
func targetLabel(target service.GrantTarget) string {
|
||||
if target.Username != "" {
|
||||
return "@" + target.Username
|
||||
}
|
||||
return strconv.FormatInt(target.TelegramID, 10)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package access
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
)
|
||||
|
||||
func TestCommandArgument(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
text string
|
||||
want string
|
||||
}{
|
||||
{name: "no argument", text: "/allow", want: ""},
|
||||
{name: "username", text: "/allow @alice_1", want: "@alice_1"},
|
||||
{name: "numeric id", text: "/kick 12345", want: "12345"},
|
||||
{name: "command with bot suffix", text: "/allow@pllist_bot alice_1", want: "alice_1"},
|
||||
{name: "extra words", text: "/allow alice_1 extra", want: "alice_1 extra"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := commandArgument(tt.text); got != tt.want {
|
||||
t.Fatalf("commandArgument(%q) = %q, want %q", tt.text, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetLabel(t *testing.T) {
|
||||
if got := targetLabel(service.GrantTarget{Username: "alice_1"}); got != "@alice_1" {
|
||||
t.Fatalf("targetLabel(username) = %q, want @alice_1", got)
|
||||
}
|
||||
if got := targetLabel(service.GrantTarget{TelegramID: 12345}); got != "12345" {
|
||||
t.Fatalf("targetLabel(id) = %q, want 12345", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package access
|
||||
|
||||
const (
|
||||
ownerOnlyText = "Only the bot owner can do that."
|
||||
)
|
||||
@@ -0,0 +1,111 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
accesstelegram "git.entityseven.com/rilaxik/pllist/internal/telegram/access"
|
||||
itemtelegram "git.entityseven.com/rilaxik/pllist/internal/telegram/item"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/list"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/menu"
|
||||
sharetelegram "git.entityseven.com/rilaxik/pllist/internal/telegram/share"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
shutdownTimeout = 10 * time.Second
|
||||
updateTimeout = 30 * time.Second
|
||||
)
|
||||
|
||||
type Bot struct {
|
||||
api *telego.Bot
|
||||
handler *th.BotHandler
|
||||
logger *slog.Logger
|
||||
users *service.UserService
|
||||
botAccess *service.BotAccessService
|
||||
shares *service.ShareService
|
||||
pending *service.PendingStore
|
||||
sender *shared.Sender
|
||||
pendingRouter *shared.PendingRouter
|
||||
menu *menu.Handler
|
||||
access *accesstelegram.Handler
|
||||
list *list.Handler
|
||||
item *itemtelegram.Handler
|
||||
share *sharetelegram.Handler
|
||||
locker *keyedLocker
|
||||
}
|
||||
|
||||
func New(
|
||||
api *telego.Bot,
|
||||
updates <-chan telego.Update,
|
||||
users *service.UserService,
|
||||
lists *service.ListService,
|
||||
items *service.ItemService,
|
||||
shares *service.ShareService,
|
||||
access *service.AccessService,
|
||||
botAccess *service.BotAccessService,
|
||||
pending *service.PendingStore,
|
||||
logger *slog.Logger,
|
||||
) (*Bot, error) {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
sender := shared.NewSender(api)
|
||||
itemHandler := itemtelegram.New(sender, items, access, pending)
|
||||
bot := &Bot{
|
||||
api: api,
|
||||
logger: logger,
|
||||
users: users,
|
||||
botAccess: botAccess,
|
||||
shares: shares,
|
||||
pending: pending,
|
||||
sender: sender,
|
||||
pendingRouter: shared.NewPendingRouter(),
|
||||
menu: menu.New(sender, pending, botAccess),
|
||||
access: accesstelegram.New(sender, botAccess),
|
||||
list: list.New(sender, lists, access, pending, itemHandler),
|
||||
item: itemHandler,
|
||||
share: sharetelegram.New(sender, shares, pending),
|
||||
locker: newKeyedLocker(),
|
||||
}
|
||||
|
||||
handler, err := th.NewBotHandler(api, updates, th.WithErrorHandler(bot.handleError))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create bot handler: %w", err)
|
||||
}
|
||||
bot.handler = handler
|
||||
|
||||
handler.Use(
|
||||
th.PanicRecoveryHandler(func(recovered any) error {
|
||||
return fmt.Errorf("panic: %v", recovered)
|
||||
}),
|
||||
th.Timeout(updateTimeout),
|
||||
bot.answerCallback,
|
||||
bot.serialize,
|
||||
bot.privateOnly,
|
||||
bot.requireAccess,
|
||||
bot.withUser,
|
||||
)
|
||||
bot.register()
|
||||
|
||||
return bot, nil
|
||||
}
|
||||
|
||||
func (b *Bot) Run() error {
|
||||
if err := b.handler.Start(); err != nil {
|
||||
return fmt.Errorf("start bot handler: %w", err)
|
||||
}
|
||||
|
||||
stopCtx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
|
||||
defer cancel()
|
||||
if err := b.handler.StopWithContext(stopCtx); err != nil {
|
||||
return fmt.Errorf("stop bot handler: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
ta "github.com/mymmrac/telego/telegoapi"
|
||||
)
|
||||
|
||||
const (
|
||||
apiMaxAttempts = 3
|
||||
apiMaxDelay = 30 * time.Second
|
||||
)
|
||||
|
||||
// NewCaller returns the Telegram API caller. It retries transient failures and
|
||||
// honors 429 retry_after, waiting only when the requested delay fits apiMaxDelay.
|
||||
func NewCaller() ta.Caller {
|
||||
return newCaller()
|
||||
}
|
||||
|
||||
func newCaller() *ta.RetryCaller {
|
||||
return &ta.RetryCaller{
|
||||
Caller: ta.DefaultFastHTTPCaller,
|
||||
MaxAttempts: apiMaxAttempts,
|
||||
ExponentBase: 2,
|
||||
StartDelay: time.Second,
|
||||
MaxDelay: apiMaxDelay,
|
||||
RateLimit: ta.RetryRateLimitWaitOrAbort,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
ta "github.com/mymmrac/telego/telegoapi"
|
||||
)
|
||||
|
||||
func TestNewCallerHandlesRateLimits(t *testing.T) {
|
||||
caller := newCaller()
|
||||
|
||||
if caller.RateLimit != ta.RetryRateLimitWaitOrAbort {
|
||||
t.Fatalf("RateLimit = %v, want RetryRateLimitWaitOrAbort", caller.RateLimit)
|
||||
}
|
||||
if caller.MaxAttempts < 2 {
|
||||
t.Fatalf("MaxAttempts = %d, want at least 2", caller.MaxAttempts)
|
||||
}
|
||||
if caller.MaxDelay != apiMaxDelay || caller.MaxDelay <= 0 {
|
||||
t.Fatalf("MaxDelay = %v, want %v", caller.MaxDelay, apiMaxDelay)
|
||||
}
|
||||
if caller.StartDelay <= 0 || caller.StartDelay > caller.MaxDelay {
|
||||
t.Fatalf("StartDelay = %v, MaxDelay = %v", caller.StartDelay, caller.MaxDelay)
|
||||
}
|
||||
if caller.ExponentBase <= 1 {
|
||||
t.Fatalf("ExponentBase = %v, want > 1", caller.ExponentBase)
|
||||
}
|
||||
if caller.Caller == nil {
|
||||
t.Fatal("Caller is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryCallerWaitsForRetryAfter(t *testing.T) {
|
||||
requests := 0
|
||||
inner := callerFunc(func() error {
|
||||
requests++
|
||||
return &ta.Error{
|
||||
ErrorCode: 429,
|
||||
Parameters: &ta.ResponseParameters{
|
||||
RetryAfter: 1,
|
||||
},
|
||||
}
|
||||
})
|
||||
caller := &ta.RetryCaller{
|
||||
Caller: inner,
|
||||
MaxAttempts: 2,
|
||||
ExponentBase: 2,
|
||||
StartDelay: time.Millisecond,
|
||||
MaxDelay: time.Second,
|
||||
RateLimit: ta.RetryRateLimitWaitOrAbort,
|
||||
}
|
||||
|
||||
_, err := caller.Call(t.Context(), "https://example.invalid", &ta.RequestData{BodyRaw: []byte("{}")})
|
||||
if err == nil {
|
||||
t.Fatal("Call() error = nil, want retry exhaustion")
|
||||
}
|
||||
if requests != 2 {
|
||||
t.Fatalf("Call() made %d requests, want 2", requests)
|
||||
}
|
||||
}
|
||||
|
||||
type callerFunc func() error
|
||||
|
||||
func (f callerFunc) Call(
|
||||
_ context.Context, _ string, _ *ta.RequestData,
|
||||
) (*ta.Response, error) {
|
||||
if err := f(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ta.Response{Ok: true}, nil
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
func (b *Bot) handleUnknownCommand(ctx *th.Context, message telego.Message) error {
|
||||
return b.sender.Send(ctx, message.Chat.ID, unknownCommandText, nil)
|
||||
}
|
||||
|
||||
func (b *Bot) handleText(ctx *th.Context, message telego.Message) error {
|
||||
user := shared.UserFrom(ctx)
|
||||
pending, ok := b.pending.Take(user.TelegramID)
|
||||
if !ok {
|
||||
return b.sender.Send(ctx, message.Chat.ID, helpHintText, nil)
|
||||
}
|
||||
|
||||
handled, err := b.pendingRouter.Dispatch(ctx, user, pending, message)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !handled {
|
||||
return b.sender.Send(ctx, message.Chat.ID, promptExpiredText, nil)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *Bot) handleOtherMessage(ctx *th.Context, message telego.Message) error {
|
||||
if _, ok := b.pending.Get(shared.UserFrom(ctx).TelegramID); !ok {
|
||||
return nil
|
||||
}
|
||||
return b.sender.Send(ctx, message.Chat.ID, textOnlyText, nil)
|
||||
}
|
||||
|
||||
// handleUnknownCallback catches callbacks that no feature understands, such as
|
||||
// buttons from an older bot version. The callback is answered by middleware;
|
||||
// send a short hint when the message is still available.
|
||||
func (b *Bot) handleUnknownCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
if query.Message == nil {
|
||||
return nil
|
||||
}
|
||||
return b.sender.Send(ctx, shared.QueryChatID(query), staleCallbackText, nil)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package item
|
||||
|
||||
import "git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
|
||||
const (
|
||||
callbackPage = shared.ActionItemPage
|
||||
callbackAdd = shared.ActionItemAdd
|
||||
callbackItem = shared.ActionItemOpen
|
||||
|
||||
callbackManage = shared.ActionItemManage
|
||||
callbackEditTitle = shared.ActionItemEditTitle
|
||||
callbackEditNote = shared.ActionItemEditNote
|
||||
callbackEditQuantity = shared.ActionItemEditQuantity
|
||||
callbackClearNote = shared.ActionItemClearNote
|
||||
callbackUrgencyLow = shared.ActionItemUrgencyLow
|
||||
callbackUrgencyNorm = shared.ActionItemUrgencyNorm
|
||||
callbackUrgencyHigh = shared.ActionItemUrgencyHigh
|
||||
callbackDone = shared.ActionItemDone
|
||||
callbackReopen = shared.ActionItemReopen
|
||||
callbackReserve = shared.ActionItemReserve
|
||||
callbackRelease = shared.ActionItemRelease
|
||||
callbackDelete = shared.ActionItemDelete
|
||||
callbackDeleteYes = shared.ActionItemDeleteYes
|
||||
)
|
||||
|
||||
func addData(listID int64) string {
|
||||
return shared.CallbackWithID(callbackAdd, listID)
|
||||
}
|
||||
|
||||
func itemData(itemID int64) string {
|
||||
return shared.CallbackWithID(callbackItem, itemID)
|
||||
}
|
||||
|
||||
func manageData(listID int64) string {
|
||||
return shared.CallbackWithID(callbackManage, listID)
|
||||
}
|
||||
|
||||
func pageData(listID int64, page int) string {
|
||||
return shared.PageData(listID, page)
|
||||
}
|
||||
|
||||
func parsePage(data string) (int64, int, error) {
|
||||
return shared.ParsePage(data)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package item
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPageDataRoundTrip(t *testing.T) {
|
||||
data := pageData(42, 3)
|
||||
listID, page, err := parsePage(data)
|
||||
if err != nil {
|
||||
t.Fatalf("parsePage(%q) error = %v", data, err)
|
||||
}
|
||||
if listID != 42 || page != 3 {
|
||||
t.Fatalf("parsePage(%q) = %d, %d", data, listID, page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePageRejectsBadData(t *testing.T) {
|
||||
for _, data := range []string{
|
||||
"v1:page",
|
||||
"v1:page:42",
|
||||
"v1:page:0:1",
|
||||
"v1:page:42:0",
|
||||
"v1:page:42:abc",
|
||||
"v1:page:42:1:extra",
|
||||
"page:42:1",
|
||||
} {
|
||||
if _, _, err := parsePage(data); err == nil {
|
||||
t.Fatalf("parsePage(%q) accepted invalid data", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
package item
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
func (h *Handler) listCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, err := shared.CallbackID(query.Data, shared.ActionListOpen)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.ShowList(ctx, shared.UserFrom(ctx).ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), listID)
|
||||
}
|
||||
|
||||
func (h *Handler) pageCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, page, err := parsePage(query.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.ShowItems(ctx, shared.UserFrom(ctx).ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), listID, page)
|
||||
}
|
||||
|
||||
func (h *Handler) manageCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, err := shared.CallbackID(query.Data, callbackManage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.ShowItems(ctx, shared.UserFrom(ctx).ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), listID, 1)
|
||||
}
|
||||
|
||||
func (h *Handler) addCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, err := shared.CallbackID(query.Data, callbackAdd)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
if _, err := h.access.Authorize(ctx, user.ID, listID, domain.ActionAddItem); err != nil {
|
||||
return err
|
||||
}
|
||||
h.pending.Set(user.TelegramID, service.Pending{
|
||||
Action: service.PendingCreateItem,
|
||||
ListID: listID,
|
||||
})
|
||||
return h.sender.Send(ctx, shared.QueryChatID(query), addItemPrompt, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) itemCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
itemID, err := shared.CallbackID(query.Data, callbackItem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.showItem(ctx, shared.UserFrom(ctx).ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), itemID)
|
||||
}
|
||||
|
||||
func (h *Handler) editTitleCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.promptItemEdit(ctx, query, callbackEditTitle, service.PendingRenameItem, renameItemPrompt)
|
||||
}
|
||||
|
||||
func (h *Handler) editNoteCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.promptItemEdit(ctx, query, callbackEditNote, service.PendingEditItemNote, notePrompt)
|
||||
}
|
||||
|
||||
func (h *Handler) editQuantityCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.promptItemEdit(ctx, query, callbackEditQuantity, service.PendingEditItemQuantity, quantityPrompt)
|
||||
}
|
||||
|
||||
func (h *Handler) promptItemEdit(
|
||||
ctx *th.Context,
|
||||
query telego.CallbackQuery,
|
||||
callbackAction shared.CallbackAction,
|
||||
action service.PendingAction,
|
||||
prompt string,
|
||||
) error {
|
||||
itemID, err := shared.CallbackID(query.Data, callbackAction)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
if _, err := h.items.Authorize(ctx, user.ID, itemID, domain.ActionEditItem); err != nil {
|
||||
return err
|
||||
}
|
||||
h.pending.Set(user.TelegramID, service.Pending{
|
||||
Action: action,
|
||||
ItemID: itemID,
|
||||
})
|
||||
return h.sender.Send(ctx, shared.QueryChatID(query), prompt, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) clearNoteCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
itemID, err := shared.CallbackID(query.Data, callbackClearNote)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
if err := h.items.SetNote(ctx, user.ID, itemID, ""); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.showItem(ctx, user.ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), itemID)
|
||||
}
|
||||
|
||||
func (h *Handler) lowUrgencyCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.setUrgency(ctx, query, callbackUrgencyLow, domain.UrgencyLow)
|
||||
}
|
||||
|
||||
func (h *Handler) normalUrgencyCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.setUrgency(ctx, query, callbackUrgencyNorm, domain.UrgencyNormal)
|
||||
}
|
||||
|
||||
func (h *Handler) highUrgencyCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.setUrgency(ctx, query, callbackUrgencyHigh, domain.UrgencyHigh)
|
||||
}
|
||||
|
||||
func (h *Handler) setUrgency(
|
||||
ctx *th.Context, query telego.CallbackQuery, action shared.CallbackAction, urgency domain.Urgency,
|
||||
) error {
|
||||
itemID, err := shared.CallbackID(query.Data, action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
if err := h.items.SetUrgency(ctx, user.ID, itemID, urgency); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.showItem(ctx, user.ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), itemID)
|
||||
}
|
||||
|
||||
func (h *Handler) doneCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.mutateItem(ctx, query, callbackDone, h.items.SetDone)
|
||||
}
|
||||
|
||||
func (h *Handler) reopenCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.mutateItem(ctx, query, callbackReopen, h.items.Reopen)
|
||||
}
|
||||
|
||||
func (h *Handler) reserveCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.mutateItem(ctx, query, callbackReserve, h.items.Reserve)
|
||||
}
|
||||
|
||||
func (h *Handler) releaseCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.mutateItem(ctx, query, callbackRelease, h.items.Release)
|
||||
}
|
||||
|
||||
func (h *Handler) mutateItem(
|
||||
ctx *th.Context,
|
||||
query telego.CallbackQuery,
|
||||
action shared.CallbackAction,
|
||||
mutate func(context.Context, int64, int64) error,
|
||||
) error {
|
||||
itemID, err := shared.CallbackID(query.Data, action)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
if err := mutate(ctx, user.ID, itemID); err != nil && !errors.Is(err, domain.ErrConflict) {
|
||||
return err
|
||||
}
|
||||
return h.showItem(ctx, user.ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), itemID)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
itemID, err := shared.CallbackID(query.Data, callbackDelete)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
if _, err := h.items.Authorize(ctx, user.ID, itemID, domain.ActionDeleteItem); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query),
|
||||
deleteItemText, deleteItemKeyboard(itemID))
|
||||
}
|
||||
|
||||
func (h *Handler) deleteYesCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
itemID, err := shared.CallbackID(query.Data, callbackDeleteYes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
itemAccess, err := h.items.Authorize(ctx, user.ID, itemID, domain.ActionDeleteItem)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.items.Delete(ctx, user.ID, itemID); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.ShowItems(ctx, user.ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), itemAccess.Item.ListID, 1)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package item
|
||||
|
||||
import (
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
sender *shared.Sender
|
||||
items *service.ItemService
|
||||
access *service.AccessService
|
||||
pending *service.PendingStore
|
||||
}
|
||||
|
||||
func New(
|
||||
sender *shared.Sender,
|
||||
items *service.ItemService,
|
||||
access *service.AccessService,
|
||||
pending *service.PendingStore,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
sender: sender,
|
||||
items: items,
|
||||
access: access,
|
||||
pending: pending,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingRouter) {
|
||||
callbacks := router.Group(th.AnyCallbackQueryWithMessage())
|
||||
callbacks.HandleCallbackQuery(h.listCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionListOpen)))
|
||||
callbacks.HandleCallbackQuery(h.pageCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackPage)))
|
||||
callbacks.HandleCallbackQuery(h.addCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackAdd)))
|
||||
callbacks.HandleCallbackQuery(h.itemCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackItem)))
|
||||
callbacks.HandleCallbackQuery(h.manageCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackManage)))
|
||||
callbacks.HandleCallbackQuery(h.editTitleCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackEditTitle)))
|
||||
callbacks.HandleCallbackQuery(h.editNoteCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackEditNote)))
|
||||
callbacks.HandleCallbackQuery(h.editQuantityCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackEditQuantity)))
|
||||
callbacks.HandleCallbackQuery(h.clearNoteCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackClearNote)))
|
||||
callbacks.HandleCallbackQuery(h.lowUrgencyCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackUrgencyLow)))
|
||||
callbacks.HandleCallbackQuery(h.normalUrgencyCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackUrgencyNorm)))
|
||||
callbacks.HandleCallbackQuery(h.highUrgencyCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackUrgencyHigh)))
|
||||
callbacks.HandleCallbackQuery(h.doneCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackDone)))
|
||||
callbacks.HandleCallbackQuery(h.reopenCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackReopen)))
|
||||
callbacks.HandleCallbackQuery(h.reserveCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackReserve)))
|
||||
callbacks.HandleCallbackQuery(h.releaseCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackRelease)))
|
||||
callbacks.HandleCallbackQuery(h.deleteCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackDelete)))
|
||||
callbacks.HandleCallbackQuery(h.deleteYesCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackDeleteYes)))
|
||||
|
||||
pendingRouter.Register(service.PendingCreateItem, h.createText)
|
||||
pendingRouter.Register(service.PendingRenameItem, h.renameText)
|
||||
pendingRouter.Register(service.PendingEditItemNote, h.noteText)
|
||||
pendingRouter.Register(service.PendingEditItemQuantity, h.quantityText)
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
package item
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
func listMenuKeyboard(itemPage service.ItemPage) *telego.InlineKeyboardMarkup {
|
||||
access := itemPage.Access
|
||||
rows := make([][]telego.InlineKeyboardButton, 0, 5)
|
||||
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Manage items", manageData(access.List.ID)),
|
||||
))
|
||||
if domain.Allowed(access.Role, domain.ActionAddItem) {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Add item", addData(access.List.ID)),
|
||||
))
|
||||
}
|
||||
if access.Role == domain.RoleOwner {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Rename list", shared.RenameListData(access.List.ID)),
|
||||
shared.Button("Delete list", shared.DeleteListData(access.List.ID)),
|
||||
), tu.InlineKeyboardRow(
|
||||
shared.Button("Shares", shared.SharesData(access.List.ID)),
|
||||
))
|
||||
}
|
||||
return tu.InlineKeyboard(rows...)
|
||||
}
|
||||
|
||||
func itemsKeyboard(itemPage service.ItemPage, userID int64) *telego.InlineKeyboardMarkup {
|
||||
rows := make([][]telego.InlineKeyboardButton, 0, len(itemPage.Items)+3)
|
||||
for _, item := range itemPage.Items {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button(itemLabel(item, userID), itemData(item.ID)),
|
||||
))
|
||||
}
|
||||
|
||||
if itemPage.PageCount() > 1 {
|
||||
navigation := make([]telego.InlineKeyboardButton, 0, 2)
|
||||
if itemPage.HasPrev() {
|
||||
navigation = append(navigation, shared.Button(
|
||||
"Prev", pageData(itemPage.Access.List.ID, itemPage.Page-1),
|
||||
))
|
||||
}
|
||||
if itemPage.HasNext() {
|
||||
navigation = append(navigation, shared.Button(
|
||||
"Next", pageData(itemPage.Access.List.ID, itemPage.Page+1),
|
||||
))
|
||||
}
|
||||
rows = append(rows, tu.InlineKeyboardRow(navigation...))
|
||||
}
|
||||
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Back", shared.ListData(itemPage.Access.List.ID)),
|
||||
))
|
||||
return tu.InlineKeyboard(rows...)
|
||||
}
|
||||
|
||||
func itemKeyboard(itemAccess service.ItemAccess) *telego.InlineKeyboardMarkup {
|
||||
item := itemAccess.Item
|
||||
role := itemAccess.Access.Role
|
||||
rows := make([][]telego.InlineKeyboardButton, 0, 6)
|
||||
|
||||
if domain.Allowed(role, domain.ActionReserveItem) && item.Status == domain.ItemStatusOpen {
|
||||
if item.Reserved() {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Release", shared.CallbackWithID(callbackRelease, item.ID)),
|
||||
))
|
||||
} else {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Reserve", shared.CallbackWithID(callbackReserve, item.ID)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if domain.Allowed(role, domain.ActionChangeItemState) {
|
||||
if item.Done() {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Reopen", shared.CallbackWithID(callbackReopen, item.ID)),
|
||||
))
|
||||
} else {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Mark done", shared.CallbackWithID(callbackDone, item.ID)),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
if domain.Allowed(role, domain.ActionEditItem) {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Edit title", shared.CallbackWithID(callbackEditTitle, item.ID)),
|
||||
shared.Button("Edit note", shared.CallbackWithID(callbackEditNote, item.ID)),
|
||||
shared.Button("Quantity", shared.CallbackWithID(callbackEditQuantity, item.ID)),
|
||||
))
|
||||
if item.Note != "" {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Clear note", shared.CallbackWithID(callbackClearNote, item.ID)),
|
||||
))
|
||||
}
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button(urgencyLabel("Low", domain.UrgencyLow, item.Urgency),
|
||||
shared.CallbackWithID(callbackUrgencyLow, item.ID)),
|
||||
shared.Button(urgencyLabel("Normal", domain.UrgencyNormal, item.Urgency),
|
||||
shared.CallbackWithID(callbackUrgencyNorm, item.ID)),
|
||||
shared.Button(urgencyLabel("High", domain.UrgencyHigh, item.Urgency),
|
||||
shared.CallbackWithID(callbackUrgencyHigh, item.ID)),
|
||||
))
|
||||
}
|
||||
|
||||
if domain.Allowed(role, domain.ActionDeleteItem) {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Delete item", shared.CallbackWithID(callbackDelete, item.ID)),
|
||||
))
|
||||
}
|
||||
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("Back to items", manageData(item.ListID)),
|
||||
))
|
||||
return tu.InlineKeyboard(rows...)
|
||||
}
|
||||
|
||||
func deleteItemKeyboard(itemID int64) *telego.InlineKeyboardMarkup {
|
||||
return tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
shared.Button("Yes, delete", shared.CallbackWithID(callbackDeleteYes, itemID)),
|
||||
shared.Button("Cancel", shared.CallbackWithID(callbackItem, itemID)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func urgencyLabel(label string, urgency, current domain.Urgency) string {
|
||||
text := urgencyEmoji(urgency) + " " + label
|
||||
if urgency == current {
|
||||
return "• " + text
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package item
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
)
|
||||
|
||||
func TestListMenuKeyboard(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
role domain.Role
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "owner",
|
||||
role: domain.RoleOwner,
|
||||
want: []string{"Manage items", "Add item", "Rename list", "Delete list", "Shares"},
|
||||
},
|
||||
{
|
||||
name: "editor",
|
||||
role: domain.RoleEditor,
|
||||
want: []string{"Manage items", "Add item"},
|
||||
},
|
||||
{
|
||||
name: "reader",
|
||||
role: domain.RoleReader,
|
||||
want: []string{"Manage items"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
keyboard := listMenuKeyboard(service.ItemPage{
|
||||
Access: domain.Access{
|
||||
List: domain.List{ID: 7},
|
||||
Role: tt.role,
|
||||
},
|
||||
})
|
||||
if got := buttonTexts(keyboard); !slices.Equal(got, tt.want) {
|
||||
t.Fatalf("listMenuKeyboard() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemsKeyboardHasPaginationAndBack(t *testing.T) {
|
||||
keyboard := itemsKeyboard(service.ItemPage{
|
||||
Access: domain.Access{
|
||||
List: domain.List{ID: 7},
|
||||
Role: domain.RoleOwner,
|
||||
},
|
||||
Items: []domain.Item{
|
||||
{ID: 1, Title: "Milk", Quantity: 2, Urgency: domain.UrgencyNormal},
|
||||
},
|
||||
Page: 1,
|
||||
Total: service.ItemPageSize + 1,
|
||||
}, 1)
|
||||
|
||||
want := []string{"🟡 Milk x2", "Next", "Back"}
|
||||
if got := buttonTexts(keyboard); !slices.Equal(got, want) {
|
||||
t.Fatalf("itemsKeyboard() = %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func buttonTexts(keyboard *telego.InlineKeyboardMarkup) []string {
|
||||
var texts []string
|
||||
for _, row := range keyboard.InlineKeyboard {
|
||||
for _, button := range row {
|
||||
texts = append(texts, button.Text)
|
||||
}
|
||||
}
|
||||
return texts
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package item
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
)
|
||||
|
||||
func (h *Handler) createText(
|
||||
ctx *th.Context, user domain.User, pending service.Pending, message telego.Message,
|
||||
) error {
|
||||
item, err := h.items.Create(ctx, user.ID, pending.ListID, message.Text)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
h.pending.Set(user.TelegramID, pending)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return h.showItem(ctx, user.ID, message.Chat.ID, 0, item.ID)
|
||||
}
|
||||
|
||||
func (h *Handler) renameText(
|
||||
ctx *th.Context, user domain.User, pending service.Pending, message telego.Message,
|
||||
) error {
|
||||
if err := h.items.Rename(ctx, user.ID, pending.ItemID, message.Text); err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
h.pending.Set(user.TelegramID, pending)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return h.showItem(ctx, user.ID, message.Chat.ID, 0, pending.ItemID)
|
||||
}
|
||||
|
||||
func (h *Handler) noteText(
|
||||
ctx *th.Context, user domain.User, pending service.Pending, message telego.Message,
|
||||
) error {
|
||||
if err := h.items.SetNote(ctx, user.ID, pending.ItemID, message.Text); err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
h.pending.Set(user.TelegramID, pending)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return h.showItem(ctx, user.ID, message.Chat.ID, 0, pending.ItemID)
|
||||
}
|
||||
|
||||
func (h *Handler) quantityText(
|
||||
ctx *th.Context, user domain.User, pending service.Pending, message telego.Message,
|
||||
) error {
|
||||
quantity, err := strconv.ParseFloat(strings.ReplaceAll(strings.TrimSpace(message.Text), ",", "."), 64)
|
||||
if err != nil {
|
||||
h.pending.Set(user.TelegramID, pending)
|
||||
return domain.NewValidationError("Quantity must be a positive number.")
|
||||
}
|
||||
if err := h.items.SetQuantity(ctx, user.ID, pending.ItemID, quantity); err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
h.pending.Set(user.TelegramID, pending)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return h.showItem(ctx, user.ID, message.Chat.ID, 0, pending.ItemID)
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
package item
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
addItemPrompt = "Send the item title (1-200 characters). /cancel to stop."
|
||||
renameItemPrompt = "Send the new item title (1-200 characters). /cancel to stop."
|
||||
notePrompt = "Send the item note (up to 1000 characters). /cancel to stop."
|
||||
quantityPrompt = "Send the quantity (positive number, e.g. 1.5). /cancel to stop."
|
||||
deleteItemText = "Delete this item? This cannot be undone."
|
||||
itemTruncateLength = 60
|
||||
)
|
||||
|
||||
func listMenuText(itemPage service.ItemPage) string {
|
||||
var text strings.Builder
|
||||
fmt.Fprintf(&text, "<b>%s</b>\nRole: %s\n\n",
|
||||
shared.Escape(itemPage.Access.List.Title), itemPage.Access.Role)
|
||||
writeItemsText(&text, itemPage)
|
||||
if itemPage.Total > len(itemPage.Items) {
|
||||
fmt.Fprintf(&text, "\n\nShowing %d of %d. Open Manage items for the rest.",
|
||||
len(itemPage.Items), itemPage.Total)
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
|
||||
func itemsText(itemPage service.ItemPage) string {
|
||||
var text strings.Builder
|
||||
fmt.Fprintf(&text, "<b>%s</b>\n\n", shared.Escape(itemPage.Access.List.Title))
|
||||
writeItemsText(&text, itemPage)
|
||||
if itemPage.Total > 0 {
|
||||
fmt.Fprintf(&text, "\n\nPage %d/%d\nTap an item to manage it.",
|
||||
itemPage.Page, itemPage.PageCount())
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
|
||||
func writeItemsText(text *strings.Builder, itemPage service.ItemPage) {
|
||||
text.WriteString("<b>Items:</b>\n")
|
||||
if itemPage.Total == 0 {
|
||||
text.WriteString("No items yet.\n\n<i>0 items</i>")
|
||||
return
|
||||
}
|
||||
for _, item := range itemPage.Items {
|
||||
fmt.Fprintf(text, "%s %s\n", itemIcon(item), shared.Escape(titleWithQuantity(item)))
|
||||
}
|
||||
fmt.Fprintf(text, "\n<i>%s</i>", itemCountLabel(itemPage.Total))
|
||||
}
|
||||
|
||||
func itemCountLabel(total int) string {
|
||||
label := "items"
|
||||
if total == 1 {
|
||||
label = "item"
|
||||
}
|
||||
return fmt.Sprintf("%d %s", total, label)
|
||||
}
|
||||
|
||||
func itemText(itemAccess service.ItemAccess, userID int64) string {
|
||||
item := itemAccess.Item
|
||||
var text strings.Builder
|
||||
fmt.Fprintf(&text, "<b>%s %s</b>\nStatus: %s\n",
|
||||
itemIcon(item), shared.Escape(titleWithQuantity(item)), item.Status)
|
||||
if item.Note != "" {
|
||||
fmt.Fprintf(&text, "Note: %s\n", shared.Escape(item.Note))
|
||||
}
|
||||
|
||||
switch {
|
||||
case !item.Reserved():
|
||||
text.WriteString("Reserved: no")
|
||||
case item.ReservedByID != nil && *item.ReservedByID == userID:
|
||||
text.WriteString("Reserved: yes (you)")
|
||||
default:
|
||||
text.WriteString("Reserved: yes")
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
|
||||
func itemLabel(item domain.Item, userID int64) string {
|
||||
title := shared.Truncate(item.Title, 32)
|
||||
label := fmt.Sprintf("%s %s x%s", itemIcon(item), title, formatQuantity(item.Quantity))
|
||||
if item.Reserved() {
|
||||
if item.ReservedByID != nil && *item.ReservedByID == userID {
|
||||
label += " · yours"
|
||||
} else {
|
||||
label += " · reserved"
|
||||
}
|
||||
}
|
||||
return shared.Truncate(label, itemTruncateLength)
|
||||
}
|
||||
|
||||
func itemIcon(item domain.Item) string {
|
||||
if item.Done() {
|
||||
return "✓"
|
||||
}
|
||||
return urgencyEmoji(item.Urgency)
|
||||
}
|
||||
|
||||
func titleWithQuantity(item domain.Item) string {
|
||||
return fmt.Sprintf("%s x%s", item.Title, formatQuantity(item.Quantity))
|
||||
}
|
||||
|
||||
func formatQuantity(quantity float64) string {
|
||||
return strconv.FormatFloat(quantity, 'f', -1, 64)
|
||||
}
|
||||
|
||||
func urgencyEmoji(urgency domain.Urgency) string {
|
||||
switch urgency {
|
||||
case domain.UrgencyHigh:
|
||||
return "🔴"
|
||||
case domain.UrgencyLow:
|
||||
return "🟢"
|
||||
default:
|
||||
return "🟡"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
package item
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
)
|
||||
|
||||
func TestItemTextUsesUrgencyIconAndQuantityInTitle(t *testing.T) {
|
||||
itemAccess := service.ItemAccess{
|
||||
Item: domain.Item{
|
||||
Title: "Milk",
|
||||
Quantity: 2,
|
||||
Urgency: domain.UrgencyHigh,
|
||||
Status: domain.ItemStatusOpen,
|
||||
},
|
||||
}
|
||||
|
||||
text := itemText(itemAccess, 1)
|
||||
for _, want := range []string{"🔴 Milk x2", "Status: open", "Reserved: no"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("itemText() = %q, missing %q", text, want)
|
||||
}
|
||||
}
|
||||
for _, unwanted := range []string{"Role:", "Urgency:", "Quantity:"} {
|
||||
if strings.Contains(text, unwanted) {
|
||||
t.Fatalf("itemText() = %q, contains %q", text, unwanted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemTextUsesDoneIcon(t *testing.T) {
|
||||
itemAccess := service.ItemAccess{
|
||||
Item: domain.Item{
|
||||
Title: "Milk",
|
||||
Quantity: 1,
|
||||
Urgency: domain.UrgencyLow,
|
||||
Status: domain.ItemStatusDone,
|
||||
},
|
||||
}
|
||||
|
||||
text := itemText(itemAccess, 1)
|
||||
if !strings.Contains(text, "✓ Milk") {
|
||||
t.Fatalf("itemText() = %q, want done icon", text)
|
||||
}
|
||||
if !strings.Contains(text, "Status: done") {
|
||||
t.Fatalf("itemText() = %q, want done status", text)
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemLabel(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item domain.Item
|
||||
user int64
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "normal urgency with quantity",
|
||||
item: domain.Item{Title: "Milk", Quantity: 3, Urgency: domain.UrgencyNormal},
|
||||
user: 1,
|
||||
want: "🟡 Milk x3",
|
||||
},
|
||||
{
|
||||
name: "fractional quantity",
|
||||
item: domain.Item{Title: "Cheese", Quantity: 1.5, Urgency: domain.UrgencyNormal},
|
||||
user: 1,
|
||||
want: "🟡 Cheese x1.5",
|
||||
},
|
||||
{
|
||||
name: "single quantity still shows suffix",
|
||||
item: domain.Item{Title: "Bread", Quantity: 1, Urgency: domain.UrgencyLow},
|
||||
user: 1,
|
||||
want: "🟢 Bread x1",
|
||||
},
|
||||
{
|
||||
name: "done item",
|
||||
item: domain.Item{Title: "Eggs", Quantity: 1, Urgency: domain.UrgencyHigh, Status: domain.ItemStatusDone},
|
||||
user: 1,
|
||||
want: "✓ Eggs x1",
|
||||
},
|
||||
{
|
||||
name: "reserved by viewer",
|
||||
item: domain.Item{
|
||||
Title: "Butter",
|
||||
Quantity: 2,
|
||||
Urgency: domain.UrgencyNormal,
|
||||
ReservedByID: int64Ptr(1),
|
||||
},
|
||||
user: 1,
|
||||
want: "🟡 Butter x2 · yours",
|
||||
},
|
||||
{
|
||||
name: "reserved by someone else",
|
||||
item: domain.Item{
|
||||
Title: "Butter",
|
||||
Quantity: 2,
|
||||
Urgency: domain.UrgencyNormal,
|
||||
ReservedByID: int64Ptr(2),
|
||||
},
|
||||
user: 1,
|
||||
want: "🟡 Butter x2 · reserved",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := itemLabel(tt.item, tt.user); got != tt.want {
|
||||
t.Fatalf("itemLabel() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func int64Ptr(value int64) *int64 { return &value }
|
||||
|
||||
func TestListMenuTextShowsItems(t *testing.T) {
|
||||
text := listMenuText(service.ItemPage{
|
||||
Access: domain.Access{
|
||||
List: domain.List{Title: "Groceries"},
|
||||
Role: domain.RoleOwner,
|
||||
},
|
||||
Items: []domain.Item{
|
||||
{Title: "Milk", Quantity: 2, Urgency: domain.UrgencyHigh},
|
||||
{Title: "Bread", Quantity: 1, Urgency: domain.UrgencyLow},
|
||||
},
|
||||
Total: 2,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"<b>Groceries</b>",
|
||||
"Role: owner",
|
||||
"Items:",
|
||||
"🔴 Milk x2",
|
||||
"🟢 Bread x1",
|
||||
"2 items",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("listMenuText() = %q, missing %q", text, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListMenuTextNotesHiddenItems(t *testing.T) {
|
||||
text := listMenuText(service.ItemPage{
|
||||
Access: domain.Access{
|
||||
List: domain.List{Title: "Groceries"},
|
||||
Role: domain.RoleReader,
|
||||
},
|
||||
Items: []domain.Item{
|
||||
{Title: "Milk", Quantity: 1, Urgency: domain.UrgencyNormal},
|
||||
},
|
||||
Total: service.ItemPageSize + 1,
|
||||
})
|
||||
|
||||
for _, want := range []string{"11 items", "Showing 1 of 11", "Manage items"} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("listMenuText() = %q, missing %q", text, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestItemsTextShowsItemsAndPagination(t *testing.T) {
|
||||
text := itemsText(service.ItemPage{
|
||||
Access: domain.Access{
|
||||
List: domain.List{Title: "Groceries"},
|
||||
Role: domain.RoleEditor,
|
||||
},
|
||||
Items: []domain.Item{
|
||||
{Title: "Milk", Quantity: 2, Urgency: domain.UrgencyHigh},
|
||||
},
|
||||
Page: 2,
|
||||
Total: service.ItemPageSize + 1,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"Items:",
|
||||
"🔴 Milk x2",
|
||||
"11 items",
|
||||
"Page 2/2",
|
||||
"Tap an item to manage it.",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("itemsText() = %q, missing %q", text, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package item
|
||||
|
||||
import "context"
|
||||
|
||||
func (h *Handler) ShowList(
|
||||
ctx context.Context, userID, chatID int64, messageID int, listID int64,
|
||||
) error {
|
||||
itemPage, err := h.items.List(ctx, userID, listID, 1)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.sender.EditOrSend(ctx, chatID, messageID,
|
||||
listMenuText(itemPage), listMenuKeyboard(itemPage))
|
||||
}
|
||||
|
||||
func (h *Handler) ShowItems(
|
||||
ctx context.Context, userID, chatID int64, messageID int, listID int64, page int,
|
||||
) error {
|
||||
itemPage, err := h.items.List(ctx, userID, listID, page)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.sender.EditOrSend(ctx, chatID, messageID,
|
||||
itemsText(itemPage), itemsKeyboard(itemPage, userID))
|
||||
}
|
||||
|
||||
func (h *Handler) showItem(
|
||||
ctx context.Context, userID, chatID int64, messageID int, itemID int64,
|
||||
) error {
|
||||
itemAccess, err := h.items.Get(ctx, userID, itemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.sender.EditOrSend(ctx, chatID, messageID,
|
||||
itemText(itemAccess, userID), itemKeyboard(itemAccess))
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
func (h *Handler) listsCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.showLists(ctx, shared.UserFrom(ctx).ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query))
|
||||
}
|
||||
|
||||
func (h *Handler) newCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.startCreateList(ctx, shared.QueryChatID(query), shared.UserFrom(ctx).TelegramID)
|
||||
}
|
||||
|
||||
func (h *Handler) renameCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, err := shared.CallbackID(query.Data, shared.ActionListRename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := h.access.Authorize(ctx, shared.UserFrom(ctx).ID, listID, domain.ActionEditListMeta); err != nil {
|
||||
return err
|
||||
}
|
||||
h.pending.Set(shared.UserFrom(ctx).TelegramID, service.Pending{
|
||||
Action: service.PendingRenameList,
|
||||
ListID: listID,
|
||||
})
|
||||
return h.sender.Send(ctx, shared.QueryChatID(query), renameTitlePrompt, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) deleteCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, err := shared.CallbackID(query.Data, shared.ActionListDelete)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := h.access.Authorize(ctx, shared.UserFrom(ctx).ID, listID, domain.ActionDeleteList); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query),
|
||||
deleteText, deleteKeyboard(listID))
|
||||
}
|
||||
|
||||
func (h *Handler) deleteYesCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, err := shared.CallbackID(query.Data, shared.ActionListDeleteYes)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := h.lists.Delete(ctx, shared.UserFrom(ctx).ID, listID); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.showLists(ctx, shared.UserFrom(ctx).ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query))
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
func (h *Handler) handleLists(ctx *th.Context, message telego.Message) error {
|
||||
return h.showLists(ctx, shared.UserFrom(ctx).ID, message.Chat.ID, 0)
|
||||
}
|
||||
|
||||
func (h *Handler) handleNew(ctx *th.Context, message telego.Message) error {
|
||||
return h.startCreateList(ctx, message.Chat.ID, shared.UserFrom(ctx).TelegramID)
|
||||
}
|
||||
|
||||
func (h *Handler) startCreateList(ctx *th.Context, chatID, telegramID int64) error {
|
||||
h.pending.Set(telegramID, service.Pending{Action: service.PendingCreateList})
|
||||
return h.sender.Send(ctx, chatID, titlePrompt, nil)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/item"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
sender *shared.Sender
|
||||
lists *service.ListService
|
||||
access *service.AccessService
|
||||
pending *service.PendingStore
|
||||
items *item.Handler
|
||||
}
|
||||
|
||||
func New(
|
||||
sender *shared.Sender,
|
||||
lists *service.ListService,
|
||||
access *service.AccessService,
|
||||
pending *service.PendingStore,
|
||||
items *item.Handler,
|
||||
) *Handler {
|
||||
return &Handler{
|
||||
sender: sender,
|
||||
lists: lists,
|
||||
access: access,
|
||||
pending: pending,
|
||||
items: items,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingRouter) {
|
||||
router.HandleMessage(h.handleLists, th.CommandEqual("lists"))
|
||||
router.HandleMessage(h.handleLists, th.TextEqual(shared.MyListsLabel))
|
||||
router.HandleMessage(h.handleNew, th.CommandEqual("new"))
|
||||
|
||||
callbacks := router.Group(th.AnyCallbackQueryWithMessage())
|
||||
callbacks.HandleCallbackQuery(h.listsCallback, th.CallbackDataEqual(shared.NavLists))
|
||||
callbacks.HandleCallbackQuery(h.newCallback, th.CallbackDataEqual(shared.NavNew))
|
||||
callbacks.HandleCallbackQuery(h.renameCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionListRename)))
|
||||
callbacks.HandleCallbackQuery(h.deleteCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionListDelete)))
|
||||
callbacks.HandleCallbackQuery(h.deleteYesCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionListDeleteYes)))
|
||||
|
||||
pendingRouter.Register(service.PendingCreateList, h.createText)
|
||||
pendingRouter.Register(service.PendingRenameList, h.renameText)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
func listsKeyboard(lists []domain.List) *telego.InlineKeyboardMarkup {
|
||||
rows := make([][]telego.InlineKeyboardButton, 0, len(lists)+1)
|
||||
for _, list := range lists {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button(shared.Truncate(list.Title, 40), shared.ListData(list.ID)),
|
||||
))
|
||||
}
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button("New list", shared.NavNew),
|
||||
shared.Button("Help", shared.NavHelp),
|
||||
))
|
||||
return tu.InlineKeyboard(rows...)
|
||||
}
|
||||
|
||||
func deleteKeyboard(listID int64) *telego.InlineKeyboardMarkup {
|
||||
return tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
shared.Button("Yes, delete", shared.DeleteListYesData(listID)),
|
||||
shared.Button("Cancel", shared.ListData(listID)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
)
|
||||
|
||||
func (h *Handler) createText(
|
||||
ctx *th.Context, user domain.User, pending service.Pending, message telego.Message,
|
||||
) error {
|
||||
list, err := h.lists.Create(ctx, user.ID, message.Text)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
h.pending.Set(user.TelegramID, pending)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return h.items.ShowList(ctx, user.ID, message.Chat.ID, 0, list.ID)
|
||||
}
|
||||
|
||||
func (h *Handler) renameText(
|
||||
ctx *th.Context, user domain.User, pending service.Pending, message telego.Message,
|
||||
) error {
|
||||
list, err := h.lists.Rename(ctx, user.ID, pending.ListID, message.Text)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
h.pending.Set(user.TelegramID, pending)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return h.items.ShowList(ctx, user.ID, message.Chat.ID, 0, list.ID)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package list
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
deleteText = `Delete this list? This cannot be undone.`
|
||||
titlePrompt = "Send the list title (1-120 characters). /cancel to stop."
|
||||
renameTitlePrompt = "Send the new list title (1-120 characters). /cancel to stop."
|
||||
)
|
||||
|
||||
func listsText(lists []domain.List, userID int64) string {
|
||||
if len(lists) == 0 {
|
||||
return "<b>Your lists</b>\n\nNo lists yet. Create one with /new."
|
||||
}
|
||||
|
||||
var text strings.Builder
|
||||
text.WriteString("<b>Your lists</b>\n\n")
|
||||
for _, list := range lists {
|
||||
role := "shared"
|
||||
if list.OwnerID == userID {
|
||||
role = "owner"
|
||||
}
|
||||
fmt.Fprintf(&text, "• %s — %s\n", shared.Escape(list.Title), role)
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package list
|
||||
|
||||
import "context"
|
||||
|
||||
func (h *Handler) showLists(ctx context.Context, userID, chatID int64, messageID int) error {
|
||||
lists, err := h.lists.List(ctx, userID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.sender.EditOrSend(ctx, chatID, messageID, listsText(lists, userID), listsKeyboard(lists))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package telegram
|
||||
|
||||
import "sync"
|
||||
|
||||
type keyedLocker struct {
|
||||
mu sync.Mutex
|
||||
locks map[int64]*keyedLock
|
||||
}
|
||||
|
||||
type keyedLock struct {
|
||||
mu sync.Mutex
|
||||
refs int
|
||||
}
|
||||
|
||||
func newKeyedLocker() *keyedLocker {
|
||||
return &keyedLocker{locks: make(map[int64]*keyedLock)}
|
||||
}
|
||||
|
||||
func (l *keyedLocker) Lock(key int64) func() {
|
||||
l.mu.Lock()
|
||||
lock := l.locks[key]
|
||||
if lock == nil {
|
||||
lock = &keyedLock{}
|
||||
l.locks[key] = lock
|
||||
}
|
||||
lock.refs++
|
||||
l.mu.Unlock()
|
||||
|
||||
lock.mu.Lock()
|
||||
return func() {
|
||||
lock.mu.Unlock()
|
||||
l.mu.Lock()
|
||||
lock.refs--
|
||||
if lock.refs == 0 {
|
||||
delete(l.locks, key)
|
||||
}
|
||||
l.mu.Unlock()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
sender *shared.Sender
|
||||
pending *service.PendingStore
|
||||
access *service.BotAccessService
|
||||
}
|
||||
|
||||
func New(
|
||||
sender *shared.Sender,
|
||||
pending *service.PendingStore,
|
||||
access *service.BotAccessService,
|
||||
) *Handler {
|
||||
return &Handler{sender: sender, pending: pending, access: access}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router *th.BotHandler) {
|
||||
router.HandleMessage(h.start, th.CommandEqual("start"))
|
||||
router.HandleMessage(h.help, th.CommandEqual("help"))
|
||||
router.HandleMessage(h.cancel, th.CommandEqual("cancel"))
|
||||
|
||||
callbacks := router.Group(th.AnyCallbackQueryWithMessage())
|
||||
callbacks.HandleCallbackQuery(h.helpCallback, th.CallbackDataEqual(shared.NavHelp))
|
||||
}
|
||||
|
||||
func (h *Handler) start(ctx *th.Context, message telego.Message) error {
|
||||
return h.sender.Send(ctx, message.Chat.ID, startText, shared.MainKeyboard())
|
||||
}
|
||||
|
||||
func (h *Handler) help(ctx *th.Context, message telego.Message) error {
|
||||
return h.sender.Send(ctx, message.Chat.ID, h.helpText(ctx), shared.MainKeyboard())
|
||||
}
|
||||
|
||||
func (h *Handler) cancel(ctx *th.Context, message telego.Message) error {
|
||||
if h.pending.Clear(shared.UserFrom(ctx).TelegramID) {
|
||||
return h.sender.Send(ctx, message.Chat.ID, "Pending input cancelled.", nil)
|
||||
}
|
||||
return h.sender.Send(ctx, message.Chat.ID, "Nothing to cancel.", nil)
|
||||
}
|
||||
|
||||
func (h *Handler) helpCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query),
|
||||
h.helpText(ctx), nil)
|
||||
}
|
||||
|
||||
func (h *Handler) helpText(ctx *th.Context) string {
|
||||
return helpText(h.access.IsOwner(shared.UserFrom(ctx).TelegramID))
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package menu
|
||||
|
||||
const (
|
||||
startText = `<b>pllist</b>
|
||||
Keep lists of planned purchases, share them, and reserve items.
|
||||
|
||||
Use the button below or /help.`
|
||||
|
||||
baseHelpText = `<b>Commands</b>
|
||||
/lists - your lists
|
||||
/new - create a list
|
||||
/cancel - cancel pending input
|
||||
/help - this message
|
||||
|
||||
Open a list to add items, reserve them, mark them done, and manage the list.`
|
||||
|
||||
ownerHelpText = `
|
||||
|
||||
<b>Bot owner</b>
|
||||
/allow <username or id> - grant bot access
|
||||
/kick <username or id> - revoke bot access`
|
||||
)
|
||||
|
||||
func helpText(isOwner bool) string {
|
||||
if isOwner {
|
||||
return baseHelpText + ownerHelpText
|
||||
}
|
||||
return baseHelpText
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package menu
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestHelpTextShowsOwnerCommandsOnlyToOwner(t *testing.T) {
|
||||
owner := helpText(true)
|
||||
for _, want := range []string{"/allow", "/kick", "Bot owner"} {
|
||||
if !strings.Contains(owner, want) {
|
||||
t.Fatalf("owner help = %q, missing %q", owner, want)
|
||||
}
|
||||
}
|
||||
|
||||
user := helpText(false)
|
||||
for _, unwanted := range []string{"/allow", "/kick", "Bot owner"} {
|
||||
if strings.Contains(user, unwanted) {
|
||||
t.Fatalf("user help = %q, contains %q", user, unwanted)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"/lists", "/new", "/cancel", "/help"} {
|
||||
if !strings.Contains(user, want) {
|
||||
t.Fatalf("user help = %q, missing %q", user, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package telegram
|
||||
|
||||
const (
|
||||
noAccessText = "No access. Ask the bot owner to allow you."
|
||||
privateChatOnlyText = "I work in private chats only."
|
||||
unknownCommandText = "Unknown command. Use /help."
|
||||
helpHintText = "Use /help to see available commands."
|
||||
textOnlyText = "Send text only."
|
||||
promptExpiredText = "That prompt is no longer active. Use /help."
|
||||
staleCallbackText = "That button is out of date. Use /lists."
|
||||
)
|
||||
@@ -0,0 +1,103 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
func (b *Bot) answerCallback(ctx *th.Context, update telego.Update) error {
|
||||
if update.CallbackQuery != nil {
|
||||
err := b.api.AnswerCallbackQuery(ctx, &telego.AnswerCallbackQueryParams{
|
||||
CallbackQueryID: update.CallbackQuery.ID,
|
||||
})
|
||||
if err != nil {
|
||||
b.logger.Warn("answer callback failed",
|
||||
"callback_id", update.CallbackQuery.ID, "error", err)
|
||||
}
|
||||
}
|
||||
return ctx.Next(update)
|
||||
}
|
||||
|
||||
// serialize runs updates from the same Telegram user one at a time. Telego
|
||||
// handles updates in parallel, while pending prompts and list edits are stateful.
|
||||
func (b *Bot) serialize(ctx *th.Context, update telego.Update) error {
|
||||
from := updateFrom(update)
|
||||
if from == nil {
|
||||
return ctx.Next(update)
|
||||
}
|
||||
unlock := b.locker.Lock(from.ID)
|
||||
defer unlock()
|
||||
return ctx.Next(update)
|
||||
}
|
||||
|
||||
func (b *Bot) privateOnly(ctx *th.Context, update telego.Update) error {
|
||||
chatID, ok := updateChatID(update)
|
||||
if !ok {
|
||||
return ctx.Next(update)
|
||||
}
|
||||
if !isPrivateChat(update) {
|
||||
return b.sender.Send(ctx, chatID, privateChatOnlyText, nil)
|
||||
}
|
||||
return ctx.Next(update)
|
||||
}
|
||||
|
||||
// requireAccess is the bot-level gate: the owner and explicitly allowed users
|
||||
// pass, everyone else gets a single denial and no handler runs.
|
||||
func (b *Bot) requireAccess(ctx *th.Context, update telego.Update) error {
|
||||
from := updateFrom(update)
|
||||
if from == nil || from.IsBot {
|
||||
return nil
|
||||
}
|
||||
allowed, err := b.botAccess.EnsureAllowed(ctx, from.ID, from.Username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if allowed {
|
||||
return ctx.Next(update)
|
||||
}
|
||||
chatID, ok := updateChatID(update)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
return b.sender.Send(ctx, chatID, noAccessText, nil)
|
||||
}
|
||||
|
||||
func (b *Bot) withUser(ctx *th.Context, update telego.Update) error {
|
||||
from := updateFrom(update)
|
||||
if from == nil || from.IsBot {
|
||||
return nil
|
||||
}
|
||||
user, err := b.users.Ensure(ctx, from.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := b.shares.ResolvePending(ctx, user.ID, from.ID, from.Username); err != nil {
|
||||
return err
|
||||
}
|
||||
return shared.WithUser(ctx, user).Next(update)
|
||||
}
|
||||
|
||||
func (b *Bot) handleError(ctx *th.Context, update telego.Update, err error) {
|
||||
attrs := []any{"update_id", update.UpdateID}
|
||||
if from := updateFrom(update); from != nil {
|
||||
attrs = append(attrs, "user_id", from.ID)
|
||||
}
|
||||
chatID, ok := updateChatID(update)
|
||||
if ok {
|
||||
attrs = append(attrs, "chat_id", chatID)
|
||||
}
|
||||
attrs = append(attrs, "error", err)
|
||||
if shared.IsExpectedError(err) {
|
||||
b.logger.Warn("update rejected", attrs...)
|
||||
} else {
|
||||
b.logger.Error("update failed", attrs...)
|
||||
}
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if sendErr := b.sender.Send(ctx, chatID, shared.UserMessage(err), nil); sendErr != nil {
|
||||
b.logger.Error("send error message failed", "chat_id", chatID, "error", sendErr)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package telegram
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestKeyedLockerSerializesSameKey(t *testing.T) {
|
||||
locker := newKeyedLocker()
|
||||
unlock := locker.Lock(1)
|
||||
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
unlockSecond := locker.Lock(1)
|
||||
close(acquired)
|
||||
unlockSecond()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
t.Fatal("second lock acquired while first is held")
|
||||
case <-time.After(20 * time.Millisecond):
|
||||
}
|
||||
|
||||
unlock()
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("second lock was not acquired after release")
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyedLockerAllowsDifferentKeys(t *testing.T) {
|
||||
locker := newKeyedLocker()
|
||||
unlock := locker.Lock(1)
|
||||
defer unlock()
|
||||
|
||||
acquired := make(chan struct{})
|
||||
go func() {
|
||||
unlockSecond := locker.Lock(2)
|
||||
close(acquired)
|
||||
unlockSecond()
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-acquired:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("different key was blocked")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package telegram
|
||||
|
||||
import th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
func (b *Bot) register() {
|
||||
b.menu.Register(b.handler)
|
||||
b.access.Register(b.handler)
|
||||
b.list.Register(b.handler, b.pendingRouter)
|
||||
b.item.Register(b.handler, b.pendingRouter)
|
||||
b.share.Register(b.handler, b.pendingRouter)
|
||||
|
||||
// Catch-alls go last so feature routes match first.
|
||||
b.handler.HandleMessage(b.handleUnknownCommand, th.AnyCommand())
|
||||
b.handler.HandleMessage(b.handleText, th.AnyMessageWithText())
|
||||
b.handler.HandleMessage(b.handleOtherMessage, th.AnyMessage())
|
||||
b.handler.HandleCallbackQuery(b.handleUnknownCallback)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
callbackAdd = shared.ActionShareAdd
|
||||
callbackRole = shared.ActionShareRole
|
||||
callbackView = shared.ActionShareView
|
||||
callbackSet = shared.ActionShareSet
|
||||
callbackRevoke = shared.ActionShareRevoke
|
||||
callbackRevokeYes = shared.ActionShareRevokeYes
|
||||
)
|
||||
|
||||
type shareRef struct {
|
||||
listID int64
|
||||
shareID int64
|
||||
pending bool
|
||||
}
|
||||
|
||||
func addData(listID int64) string {
|
||||
return shared.ShareAddData(listID)
|
||||
}
|
||||
|
||||
func roleData(listID int64, role domain.Role) string {
|
||||
return shared.ShareRoleData(listID, role)
|
||||
}
|
||||
|
||||
func viewData(listID, shareID int64, pending bool) string {
|
||||
return shared.ShareViewData(shareRefData(listID, shareID, pending))
|
||||
}
|
||||
|
||||
func setData(listID, shareID int64, pending bool, role domain.Role) string {
|
||||
return shared.ShareSetData(shareRefData(listID, shareID, pending), role)
|
||||
}
|
||||
|
||||
func revokeData(listID, shareID int64, pending bool) string {
|
||||
return shared.ShareRevokeData(shareRefData(listID, shareID, pending))
|
||||
}
|
||||
|
||||
func revokeYesData(listID, shareID int64, pending bool) string {
|
||||
return shared.ShareRevokeYesData(shareRefData(listID, shareID, pending))
|
||||
}
|
||||
|
||||
func parseAdd(data string) (int64, error) {
|
||||
return shared.CallbackID(data, callbackAdd)
|
||||
}
|
||||
|
||||
func parseRole(data string) (int64, domain.Role, error) {
|
||||
return shared.ParseShareRole(data)
|
||||
}
|
||||
|
||||
func parseView(data string) (shareRef, error) {
|
||||
return parseRef(data, callbackView)
|
||||
}
|
||||
|
||||
func parseSet(data string) (shareRef, domain.Role, error) {
|
||||
ref, role, err := shared.ParseShareSet(data)
|
||||
if err != nil {
|
||||
return shareRef{}, "", err
|
||||
}
|
||||
return shareRef{
|
||||
listID: ref.ListID,
|
||||
shareID: ref.ShareID,
|
||||
pending: ref.Pending,
|
||||
}, role, nil
|
||||
}
|
||||
|
||||
func parseRevoke(data string) (shareRef, error) {
|
||||
return parseRef(data, callbackRevoke)
|
||||
}
|
||||
|
||||
func parseRevokeYes(data string) (shareRef, error) {
|
||||
return parseRef(data, callbackRevokeYes)
|
||||
}
|
||||
|
||||
func parseRef(data string, action shared.CallbackAction) (shareRef, error) {
|
||||
ref, err := shared.ParseShareRef(data, action)
|
||||
if err != nil {
|
||||
return shareRef{}, err
|
||||
}
|
||||
return shareRef{
|
||||
listID: ref.ListID,
|
||||
shareID: ref.ShareID,
|
||||
pending: ref.Pending,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func shareRefData(listID, shareID int64, pending bool) shared.ShareRef {
|
||||
return shared.ShareRef{ListID: listID, ShareID: shareID, Pending: pending}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
func TestShareCallbackRoundTrips(t *testing.T) {
|
||||
listID, role, err := parseRole(roleData(42, domain.RoleEditor))
|
||||
if err != nil || listID != 42 || role != domain.RoleEditor {
|
||||
t.Fatalf("parseRole() = %d, %q, %v", listID, role, err)
|
||||
}
|
||||
|
||||
for _, pending := range []bool{false, true} {
|
||||
want := shareRef{listID: 42, shareID: 7, pending: pending}
|
||||
|
||||
ref, err := parseView(viewData(42, 7, pending))
|
||||
if err != nil {
|
||||
t.Fatalf("parseView() error = %v", err)
|
||||
}
|
||||
if ref != want {
|
||||
t.Fatalf("parseView() = %+v, want %+v", ref, want)
|
||||
}
|
||||
|
||||
ref, role, err := parseSet(setData(42, 7, pending, domain.RoleReader))
|
||||
if err != nil || role != domain.RoleReader {
|
||||
t.Fatalf("parseSet() = %+v, %q, %v", ref, role, err)
|
||||
}
|
||||
if ref != want {
|
||||
t.Fatalf("parseSet() ref = %+v, want %+v", ref, want)
|
||||
}
|
||||
|
||||
ref, err = parseRevoke(revokeData(42, 7, pending))
|
||||
if err != nil {
|
||||
t.Fatalf("parseRevoke() error = %v", err)
|
||||
}
|
||||
if ref != want {
|
||||
t.Fatalf("parseRevoke() = %+v, want %+v", ref, want)
|
||||
}
|
||||
|
||||
ref, err = parseRevokeYes(revokeYesData(42, 7, pending))
|
||||
if err != nil {
|
||||
t.Fatalf("parseRevokeYes() error = %v", err)
|
||||
}
|
||||
if ref != want {
|
||||
t.Fatalf("parseRevokeYes() = %+v, want %+v", ref, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareCallbackRejectsBadData(t *testing.T) {
|
||||
for _, data := range []string{
|
||||
"v1:share_role:42",
|
||||
"v1:share_role:0:editor",
|
||||
"v1:share_role:42:owner",
|
||||
"v1:share_view:42:u",
|
||||
"v1:share_view:42:x:7",
|
||||
"v1:share_view:42:u:0",
|
||||
"v1:share_set:42:u:7",
|
||||
"v1:share_set:42:u:7:owner",
|
||||
"v1:share_revoke:42:u",
|
||||
} {
|
||||
if _, err := parseRefOrRole(data); err == nil {
|
||||
t.Fatalf("accepted invalid callback data %q", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parseRefOrRole(data string) (any, error) {
|
||||
ref, role, err := parseSet(data)
|
||||
if err == nil {
|
||||
return struct {
|
||||
ref shareRef
|
||||
role domain.Role
|
||||
}{ref, role}, nil
|
||||
}
|
||||
if ref, err := parseView(data); err == nil {
|
||||
return ref, nil
|
||||
}
|
||||
if ref, err := parseRevoke(data); err == nil {
|
||||
return ref, nil
|
||||
}
|
||||
if ref, err := parseRevokeYes(data); err == nil {
|
||||
return ref, nil
|
||||
}
|
||||
if listID, role, err := parseRole(data); err == nil {
|
||||
return struct {
|
||||
listID int64
|
||||
role domain.Role
|
||||
}{listID, role}, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
func (h *Handler) manageCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, err := shared.CallbackID(query.Data, shared.ActionShareManage)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.showShares(ctx, shared.UserFrom(ctx).ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), listID)
|
||||
}
|
||||
|
||||
func (h *Handler) addCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, err := parseAdd(query.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
if _, err := h.shares.List(ctx, user.ID, listID); err != nil {
|
||||
return err
|
||||
}
|
||||
h.pending.Set(user.TelegramID, service.Pending{
|
||||
Action: service.PendingShareTarget,
|
||||
ListID: listID,
|
||||
})
|
||||
return h.sender.Send(ctx, shared.QueryChatID(query), addSharePrompt, nil)
|
||||
}
|
||||
|
||||
func (h *Handler) roleCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
listID, role, err := parseRole(query.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
pending, ok := h.pending.Get(user.TelegramID)
|
||||
if !ok || pending.Action != service.PendingShareRole || pending.ListID != listID {
|
||||
return domain.NewValidationError(shareExpiredText)
|
||||
}
|
||||
if err := h.shares.Share(ctx, user, listID, pending.Target, role); err != nil {
|
||||
return err
|
||||
}
|
||||
h.pending.Clear(user.TelegramID)
|
||||
return h.showShares(ctx, user.ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), listID)
|
||||
}
|
||||
|
||||
func (h *Handler) viewCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
ref, err := parseView(query.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.showShare(ctx, shared.UserFrom(ctx).ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), ref)
|
||||
}
|
||||
|
||||
func (h *Handler) setRoleCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
ref, role, err := parseSet(query.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
if err := h.shares.SetRole(ctx, user.ID, ref.listID, ref.shareID, ref.pending, role); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.showShares(ctx, user.ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), ref.listID)
|
||||
}
|
||||
|
||||
func (h *Handler) revokeCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
ref, err := parseRevoke(query.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
list, share, err := h.shares.Find(ctx, user.ID, ref.listID, ref.shareID, ref.pending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.sender.EditOrSend(ctx, shared.QueryChatID(query), shared.QueryMessageID(query),
|
||||
revokeConfirmText(list, share), revokeConfirmKeyboard(ref))
|
||||
}
|
||||
|
||||
func (h *Handler) revokeYesCallback(ctx *th.Context, query telego.CallbackQuery) error {
|
||||
ref, err := parseRevokeYes(query.Data)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user := shared.UserFrom(ctx)
|
||||
if err := h.shares.Revoke(ctx, user.ID, ref.listID, ref.shareID, ref.pending); err != nil {
|
||||
return err
|
||||
}
|
||||
return h.showShares(ctx, user.ID,
|
||||
shared.QueryChatID(query), shared.QueryMessageID(query), ref.listID)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
type Handler struct {
|
||||
sender *shared.Sender
|
||||
shares *service.ShareService
|
||||
pending *service.PendingStore
|
||||
}
|
||||
|
||||
func New(
|
||||
sender *shared.Sender,
|
||||
shares *service.ShareService,
|
||||
pending *service.PendingStore,
|
||||
) *Handler {
|
||||
return &Handler{sender: sender, shares: shares, pending: pending}
|
||||
}
|
||||
|
||||
func (h *Handler) Register(router *th.BotHandler, pendingRouter *shared.PendingRouter) {
|
||||
callbacks := router.Group(th.AnyCallbackQueryWithMessage())
|
||||
callbacks.HandleCallbackQuery(h.manageCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(shared.ActionShareManage)))
|
||||
callbacks.HandleCallbackQuery(h.addCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackAdd)))
|
||||
callbacks.HandleCallbackQuery(h.roleCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackRole)))
|
||||
callbacks.HandleCallbackQuery(h.viewCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackView)))
|
||||
callbacks.HandleCallbackQuery(h.setRoleCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackSet)))
|
||||
callbacks.HandleCallbackQuery(h.revokeCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackRevoke)))
|
||||
callbacks.HandleCallbackQuery(h.revokeYesCallback,
|
||||
th.CallbackDataPrefix(shared.CallbackPrefix(callbackRevokeYes)))
|
||||
|
||||
pendingRouter.Register(service.PendingShareTarget, h.targetText)
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
func sharesKeyboard(list service.ShareList) *telego.InlineKeyboardMarkup {
|
||||
rows := make([][]telego.InlineKeyboardButton, 0, len(list.Shares)+2)
|
||||
for _, share := range list.Shares {
|
||||
rows = append(rows, tu.InlineKeyboardRow(
|
||||
shared.Button(shared.Truncate(shareLabel(share), 40),
|
||||
viewData(list.Access.List.ID, share.ID, share.Pending)),
|
||||
))
|
||||
}
|
||||
rows = append(rows,
|
||||
tu.InlineKeyboardRow(
|
||||
shared.Button("Add share", addData(list.Access.List.ID)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
shared.Button("Back to list", shared.ListData(list.Access.List.ID)),
|
||||
),
|
||||
)
|
||||
return tu.InlineKeyboard(rows...)
|
||||
}
|
||||
|
||||
func roleKeyboard(listID int64) *telego.InlineKeyboardMarkup {
|
||||
return tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
shared.Button("Reader", roleData(listID, domain.RoleReader)),
|
||||
shared.Button("Editor", roleData(listID, domain.RoleEditor)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
shared.Button("Cancel", shared.SharesData(listID)),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func shareDetailKeyboard(list service.ShareList, share domain.Share) *telego.InlineKeyboardMarkup {
|
||||
listID := list.Access.List.ID
|
||||
rows := make([][]telego.InlineKeyboardButton, 0, 4)
|
||||
roles := make([]telego.InlineKeyboardButton, 0, 2)
|
||||
if share.Role != domain.RoleReader {
|
||||
roles = append(roles, shared.Button("Make reader",
|
||||
setData(listID, share.ID, share.Pending, domain.RoleReader)))
|
||||
}
|
||||
if share.Role != domain.RoleEditor {
|
||||
roles = append(roles, shared.Button("Make editor",
|
||||
setData(listID, share.ID, share.Pending, domain.RoleEditor)))
|
||||
}
|
||||
if len(roles) > 0 {
|
||||
rows = append(rows, tu.InlineKeyboardRow(roles...))
|
||||
}
|
||||
rows = append(rows,
|
||||
tu.InlineKeyboardRow(
|
||||
shared.Button("Revoke", revokeData(listID, share.ID, share.Pending)),
|
||||
),
|
||||
tu.InlineKeyboardRow(
|
||||
shared.Button("Back to shares", shared.SharesData(listID)),
|
||||
),
|
||||
)
|
||||
return tu.InlineKeyboard(rows...)
|
||||
}
|
||||
|
||||
func revokeConfirmKeyboard(ref shareRef) *telego.InlineKeyboardMarkup {
|
||||
return tu.InlineKeyboard(
|
||||
tu.InlineKeyboardRow(
|
||||
shared.Button("Yes, revoke", revokeYesData(ref.listID, ref.shareID, ref.pending)),
|
||||
shared.Button("Cancel", viewData(ref.listID, ref.shareID, ref.pending)),
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
)
|
||||
|
||||
// targetText receives the username or ID after the Add share prompt.
|
||||
func (h *Handler) targetText(
|
||||
ctx *th.Context, user domain.User, pending service.Pending, message telego.Message,
|
||||
) error {
|
||||
label, err := h.shares.Prepare(ctx, user, pending.ListID, message.Text)
|
||||
if err != nil {
|
||||
if errors.Is(err, domain.ErrInvalidInput) {
|
||||
h.pending.Set(user.TelegramID, pending)
|
||||
}
|
||||
return err
|
||||
}
|
||||
h.pending.Set(user.TelegramID, service.Pending{
|
||||
Action: service.PendingShareRole,
|
||||
ListID: pending.ListID,
|
||||
Target: message.Text,
|
||||
})
|
||||
return h.sender.Send(ctx, message.Chat.ID, roleChoiceText(label),
|
||||
roleKeyboard(pending.ListID))
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/telegram/shared"
|
||||
)
|
||||
|
||||
const (
|
||||
addSharePrompt = "Send the Telegram @username or user ID to share with. /cancel to stop."
|
||||
shareExpiredText = "This share prompt expired. Open the shares menu again."
|
||||
)
|
||||
|
||||
func sharesText(list service.ShareList) string {
|
||||
var text strings.Builder
|
||||
fmt.Fprintf(&text, "<b>Shares: %s</b>\n\n", shared.Escape(list.Access.List.Title))
|
||||
if len(list.Shares) == 0 {
|
||||
text.WriteString("No shares yet.\n\nOnly users with bot access can be added.")
|
||||
return text.String()
|
||||
}
|
||||
|
||||
hasPending := false
|
||||
for _, share := range list.Shares {
|
||||
fmt.Fprintf(&text, "• %s — %s", shared.Escape(shareLabel(share)), share.Role)
|
||||
if share.Pending {
|
||||
text.WriteString(" (pending)")
|
||||
hasPending = true
|
||||
}
|
||||
text.WriteByte('\n')
|
||||
}
|
||||
if hasPending {
|
||||
text.WriteString("\n<i>Pending shares activate when the user contacts the bot.</i>")
|
||||
}
|
||||
return strings.TrimRight(text.String(), "\n")
|
||||
}
|
||||
|
||||
func shareDetailText(list service.ShareList, share domain.Share) string {
|
||||
var text strings.Builder
|
||||
fmt.Fprintf(&text, "<b>Share · %s</b>\n", shared.Escape(list.Access.List.Title))
|
||||
fmt.Fprintf(&text, "User: %s\nRole: %s\nStatus: %s",
|
||||
shared.Escape(shareLabel(share)), share.Role, shareStatus(share))
|
||||
if share.Pending {
|
||||
text.WriteString("\n\n<i>This share activates when the user contacts the bot.</i>")
|
||||
}
|
||||
return text.String()
|
||||
}
|
||||
|
||||
func revokeConfirmText(list service.ShareList, share domain.Share) string {
|
||||
return fmt.Sprintf(
|
||||
"<b>Revoke access?</b>\n\n%s will lose access to \"%s\".",
|
||||
shared.Escape(shareLabel(share)), shared.Escape(list.Access.List.Title),
|
||||
)
|
||||
}
|
||||
|
||||
func roleChoiceText(label string) string {
|
||||
return fmt.Sprintf("Choose a role for %s.", shared.Escape(label))
|
||||
}
|
||||
|
||||
func shareLabel(share domain.Share) string {
|
||||
switch {
|
||||
case share.Username != "":
|
||||
return "@" + share.Username
|
||||
case share.TelegramID > 0:
|
||||
return fmt.Sprintf("ID %d", share.TelegramID)
|
||||
default:
|
||||
return "Unknown user"
|
||||
}
|
||||
}
|
||||
|
||||
func shareStatus(share domain.Share) string {
|
||||
if share.Pending {
|
||||
return "pending"
|
||||
}
|
||||
return "active"
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package share
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
)
|
||||
|
||||
func TestSharesTextShowsActiveAndPendingShares(t *testing.T) {
|
||||
list := service.ShareList{
|
||||
Access: domain.Access{List: domain.List{ID: 1, Title: "Groceries"}},
|
||||
Shares: []domain.Share{
|
||||
{ID: 1, Username: "alice_1", Role: domain.RoleEditor},
|
||||
{ID: 2, TelegramID: 12345, Role: domain.RoleReader, Pending: true},
|
||||
},
|
||||
}
|
||||
|
||||
text := sharesText(list)
|
||||
for _, want := range []string{
|
||||
"<b>Shares: Groceries</b>",
|
||||
"@alice_1 — editor",
|
||||
"ID 12345 — reader (pending)",
|
||||
"Pending shares activate",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("sharesText() = %q, missing %q", text, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareDetailText(t *testing.T) {
|
||||
list := service.ShareList{
|
||||
Access: domain.Access{List: domain.List{ID: 1, Title: "Groceries"}},
|
||||
}
|
||||
text := shareDetailText(list, domain.Share{
|
||||
ID: 1, Username: "alice_1", Role: domain.RoleReader, Pending: true,
|
||||
})
|
||||
|
||||
for _, want := range []string{
|
||||
"<b>Share · Groceries</b>",
|
||||
"User: @alice_1",
|
||||
"Role: reader",
|
||||
"Status: pending",
|
||||
} {
|
||||
if !strings.Contains(text, want) {
|
||||
t.Fatalf("shareDetailText() = %q, missing %q", text, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package share
|
||||
|
||||
import "context"
|
||||
|
||||
func (h *Handler) showShares(
|
||||
ctx context.Context, userID, chatID int64, messageID int, listID int64,
|
||||
) error {
|
||||
list, err := h.shares.List(ctx, userID, listID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.sender.EditOrSend(ctx, chatID, messageID,
|
||||
sharesText(list), sharesKeyboard(list))
|
||||
}
|
||||
|
||||
func (h *Handler) showShare(
|
||||
ctx context.Context, userID, chatID int64, messageID int, ref shareRef,
|
||||
) error {
|
||||
list, share, err := h.shares.Find(ctx, userID, ref.listID, ref.shareID, ref.pending)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return h.sender.EditOrSend(ctx, chatID, messageID,
|
||||
shareDetailText(list, share), shareDetailKeyboard(list, share))
|
||||
}
|
||||
@@ -0,0 +1,305 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
const (
|
||||
callbackVersion = "v1"
|
||||
MaxCallbackDataLength = 64
|
||||
)
|
||||
|
||||
var ErrBadCallback = errors.New("invalid callback data")
|
||||
|
||||
// CallbackAction is a stable, versioned callback identifier. Actions are short
|
||||
// because Telegram limits callback data to 64 bytes.
|
||||
type CallbackAction string
|
||||
|
||||
const (
|
||||
ActionNavLists CallbackAction = "lists"
|
||||
ActionNavNew CallbackAction = "new"
|
||||
ActionNavHelp CallbackAction = "help"
|
||||
|
||||
ActionListOpen CallbackAction = "open_list"
|
||||
ActionListRename CallbackAction = "rename_list"
|
||||
ActionListDelete CallbackAction = "delete_list"
|
||||
ActionListDeleteYes CallbackAction = "delete_list_yes"
|
||||
ActionShareManage CallbackAction = "shares"
|
||||
|
||||
ActionItemPage CallbackAction = "page"
|
||||
ActionItemAdd CallbackAction = "add"
|
||||
ActionItemOpen CallbackAction = "item"
|
||||
ActionItemManage CallbackAction = "manage"
|
||||
ActionItemEditTitle CallbackAction = "edit_title"
|
||||
ActionItemEditNote CallbackAction = "edit_note"
|
||||
ActionItemEditQuantity CallbackAction = "edit_qty"
|
||||
ActionItemClearNote CallbackAction = "clear_note"
|
||||
ActionItemUrgencyLow CallbackAction = "urgency_low"
|
||||
ActionItemUrgencyNorm CallbackAction = "urgency_normal"
|
||||
ActionItemUrgencyHigh CallbackAction = "urgency_high"
|
||||
ActionItemDone CallbackAction = "done_item"
|
||||
ActionItemReopen CallbackAction = "reopen_item"
|
||||
ActionItemReserve CallbackAction = "reserve_item"
|
||||
ActionItemRelease CallbackAction = "release_item"
|
||||
ActionItemDelete CallbackAction = "delete_item"
|
||||
ActionItemDeleteYes CallbackAction = "delete_item_yes"
|
||||
|
||||
ActionShareAdd CallbackAction = "share_add"
|
||||
ActionShareRole CallbackAction = "share_role"
|
||||
ActionShareView CallbackAction = "share_view"
|
||||
ActionShareSet CallbackAction = "share_set"
|
||||
ActionShareRevoke CallbackAction = "share_revoke"
|
||||
ActionShareRevokeYes CallbackAction = "share_revoke_yes"
|
||||
)
|
||||
|
||||
// Navigation actions carry no arguments and are compared verbatim.
|
||||
const (
|
||||
NavLists = callbackVersion + ":" + string(ActionNavLists)
|
||||
NavNew = callbackVersion + ":" + string(ActionNavNew)
|
||||
NavHelp = callbackVersion + ":" + string(ActionNavHelp)
|
||||
)
|
||||
|
||||
func ListData(id int64) string {
|
||||
return CallbackWithID(ActionListOpen, id)
|
||||
}
|
||||
|
||||
func RenameListData(id int64) string {
|
||||
return CallbackWithID(ActionListRename, id)
|
||||
}
|
||||
|
||||
func DeleteListData(id int64) string {
|
||||
return CallbackWithID(ActionListDelete, id)
|
||||
}
|
||||
|
||||
func DeleteListYesData(id int64) string {
|
||||
return CallbackWithID(ActionListDeleteYes, id)
|
||||
}
|
||||
|
||||
func SharesData(id int64) string {
|
||||
return CallbackWithID(ActionShareManage, id)
|
||||
}
|
||||
|
||||
func CallbackPrefix(action CallbackAction) string {
|
||||
return callbackVersion + ":" + string(action) + ":"
|
||||
}
|
||||
|
||||
func CallbackWithID(action CallbackAction, id int64) string {
|
||||
return encodeCallback(action, strconv.FormatInt(id, 10))
|
||||
}
|
||||
|
||||
func CallbackID(data string, action CallbackAction) (int64, error) {
|
||||
args, err := callbackArgs(data, action, 1)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return parsePositiveID(args[0])
|
||||
}
|
||||
|
||||
// PageData and ParsePage cover the item pagination callback.
|
||||
func PageData(listID int64, page int) string {
|
||||
return encodeCallback(ActionItemPage,
|
||||
strconv.FormatInt(listID, 10), strconv.Itoa(page))
|
||||
}
|
||||
|
||||
func ParsePage(data string) (int64, int, error) {
|
||||
args, err := callbackArgs(data, ActionItemPage, 2)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
listID, err := parsePositiveID(args[0])
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
page, err := strconv.Atoi(args[1])
|
||||
if err != nil || page < 1 {
|
||||
return 0, 0, ErrBadCallback
|
||||
}
|
||||
return listID, page, nil
|
||||
}
|
||||
|
||||
// ShareRef identifies either an active list share or a pending one.
|
||||
type ShareRef struct {
|
||||
ListID int64
|
||||
ShareID int64
|
||||
Pending bool
|
||||
}
|
||||
|
||||
func ShareAddData(listID int64) string {
|
||||
return CallbackWithID(ActionShareAdd, listID)
|
||||
}
|
||||
|
||||
func ShareRoleData(listID int64, role domain.Role) string {
|
||||
return encodeCallback(ActionShareRole, strconv.FormatInt(listID, 10), string(role))
|
||||
}
|
||||
|
||||
func ShareViewData(ref ShareRef) string {
|
||||
return encodeShareRef(ActionShareView, ref)
|
||||
}
|
||||
|
||||
func ShareSetData(ref ShareRef, role domain.Role) string {
|
||||
args := append(shareRefArgs(ref), string(role))
|
||||
return encodeCallback(ActionShareSet, args...)
|
||||
}
|
||||
|
||||
func ShareRevokeData(ref ShareRef) string {
|
||||
return encodeShareRef(ActionShareRevoke, ref)
|
||||
}
|
||||
|
||||
func ShareRevokeYesData(ref ShareRef) string {
|
||||
return encodeShareRef(ActionShareRevokeYes, ref)
|
||||
}
|
||||
|
||||
func ParseShareRole(data string) (int64, domain.Role, error) {
|
||||
args, err := callbackArgs(data, ActionShareRole, 2)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
listID, err := parsePositiveID(args[0])
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
role, ok := parseShareRole(args[1])
|
||||
if !ok {
|
||||
return 0, "", ErrBadCallback
|
||||
}
|
||||
return listID, role, nil
|
||||
}
|
||||
|
||||
func ParseShareRef(data string, action CallbackAction) (ShareRef, error) {
|
||||
args, err := callbackArgs(data, action, 3)
|
||||
if err != nil {
|
||||
return ShareRef{}, err
|
||||
}
|
||||
return parseShareRefArgs(args)
|
||||
}
|
||||
|
||||
func ParseShareSet(data string) (ShareRef, domain.Role, error) {
|
||||
args, err := callbackArgs(data, ActionShareSet, 4)
|
||||
if err != nil {
|
||||
return ShareRef{}, "", err
|
||||
}
|
||||
ref, err := parseShareRefArgs(args[:3])
|
||||
if err != nil {
|
||||
return ShareRef{}, "", err
|
||||
}
|
||||
role, ok := parseShareRole(args[3])
|
||||
if !ok {
|
||||
return ShareRef{}, "", ErrBadCallback
|
||||
}
|
||||
return ref, role, nil
|
||||
}
|
||||
|
||||
func encodeCallback(action CallbackAction, args ...string) string {
|
||||
return callbackVersion + ":" + string(action) + ":" + strings.Join(args, ":")
|
||||
}
|
||||
|
||||
func callbackArgs(data string, action CallbackAction, count int) ([]string, error) {
|
||||
if len(data) == 0 || len(data) > MaxCallbackDataLength {
|
||||
return nil, ErrBadCallback
|
||||
}
|
||||
raw, ok := strings.CutPrefix(data, CallbackPrefix(action))
|
||||
if !ok {
|
||||
return nil, ErrBadCallback
|
||||
}
|
||||
args := strings.Split(raw, ":")
|
||||
if len(args) != count {
|
||||
return nil, ErrBadCallback
|
||||
}
|
||||
for _, arg := range args {
|
||||
if !validCallbackArg(arg) {
|
||||
return nil, ErrBadCallback
|
||||
}
|
||||
}
|
||||
return args, nil
|
||||
}
|
||||
|
||||
func validCallbackArg(arg string) bool {
|
||||
if len(arg) == 0 || len(arg) > 20 {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(arg); i++ {
|
||||
char := arg[i]
|
||||
if (char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '_' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parsePositiveID(raw string) (int64, error) {
|
||||
id, err := strconv.ParseInt(raw, 10, 64)
|
||||
if err != nil || id <= 0 {
|
||||
return 0, ErrBadCallback
|
||||
}
|
||||
return id, nil
|
||||
}
|
||||
|
||||
func shareRefArgs(ref ShareRef) []string {
|
||||
kind := "u"
|
||||
if ref.Pending {
|
||||
kind = "p"
|
||||
}
|
||||
return []string{
|
||||
strconv.FormatInt(ref.ListID, 10),
|
||||
kind,
|
||||
strconv.FormatInt(ref.ShareID, 10),
|
||||
}
|
||||
}
|
||||
|
||||
func encodeShareRef(action CallbackAction, ref ShareRef) string {
|
||||
return encodeCallback(action, shareRefArgs(ref)...)
|
||||
}
|
||||
|
||||
func parseShareRefArgs(args []string) (ShareRef, error) {
|
||||
listID, err := parsePositiveID(args[0])
|
||||
if err != nil {
|
||||
return ShareRef{}, err
|
||||
}
|
||||
shareID, err := parsePositiveID(args[2])
|
||||
if err != nil {
|
||||
return ShareRef{}, err
|
||||
}
|
||||
pending, ok := parseShareKind(args[1])
|
||||
if !ok {
|
||||
return ShareRef{}, ErrBadCallback
|
||||
}
|
||||
return ShareRef{ListID: listID, ShareID: shareID, Pending: pending}, nil
|
||||
}
|
||||
|
||||
func parseShareKind(raw string) (bool, bool) {
|
||||
switch raw {
|
||||
case "u":
|
||||
return false, true
|
||||
case "p":
|
||||
return true, true
|
||||
default:
|
||||
return false, false
|
||||
}
|
||||
}
|
||||
|
||||
func parseShareRole(raw string) (domain.Role, bool) {
|
||||
role := domain.Role(raw)
|
||||
switch role {
|
||||
case domain.RoleReader, domain.RoleEditor:
|
||||
return role, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
// QueryChatID and QueryMessageID assume the callback was routed with
|
||||
// telegohandler.AnyCallbackQueryWithMessage.
|
||||
func QueryChatID(query telego.CallbackQuery) int64 {
|
||||
return query.Message.GetChat().ID
|
||||
}
|
||||
|
||||
func QueryMessageID(query telego.CallbackQuery) int {
|
||||
return query.Message.GetMessageID()
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
func TestCallbackID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
action CallbackAction
|
||||
want int64
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "valid", data: "v1:open_list:42", action: ActionListOpen, want: 42},
|
||||
{name: "wrong action", data: "v1:rename_list:42", action: ActionListOpen, wantErr: true},
|
||||
{name: "wrong version", data: "v2:open_list:42", action: ActionListOpen, wantErr: true},
|
||||
{name: "unversioned", data: "open_list:42", action: ActionListOpen, wantErr: true},
|
||||
{name: "not a number", data: "v1:open_list:abc", action: ActionListOpen, wantErr: true},
|
||||
{name: "zero", data: "v1:open_list:0", action: ActionListOpen, wantErr: true},
|
||||
{name: "negative", data: "v1:open_list:-1", action: ActionListOpen, wantErr: true},
|
||||
{name: "missing id", data: "v1:open_list", action: ActionListOpen, wantErr: true},
|
||||
{name: "extra arg", data: "v1:open_list:42:1", action: ActionListOpen, wantErr: true},
|
||||
{name: "too long", data: "v1:open_list:" + strings.Repeat("1", MaxCallbackDataLength), action: ActionListOpen, wantErr: true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := CallbackID(tt.data, tt.action)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Fatalf("CallbackID() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Fatalf("CallbackID() = %d, want %d", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListDataRoundTrip(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
data string
|
||||
action CallbackAction
|
||||
}{
|
||||
{name: "open", data: ListData(42), action: ActionListOpen},
|
||||
{name: "rename", data: RenameListData(42), action: ActionListRename},
|
||||
{name: "delete", data: DeleteListData(42), action: ActionListDelete},
|
||||
{name: "delete yes", data: DeleteListYesData(42), action: ActionListDeleteYes},
|
||||
{name: "shares", data: SharesData(42), action: ActionShareManage},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !strings.HasPrefix(tt.data, "v1:") {
|
||||
t.Fatalf("callback data is not versioned: %q", tt.data)
|
||||
}
|
||||
id, err := CallbackID(tt.data, tt.action)
|
||||
if err != nil {
|
||||
t.Fatalf("CallbackID(%q) error = %v", tt.data, err)
|
||||
}
|
||||
if id != 42 {
|
||||
t.Fatalf("CallbackID(%q) = %d, want 42", tt.data, id)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageCallbackRoundTrip(t *testing.T) {
|
||||
data := PageData(42, 3)
|
||||
listID, page, err := ParsePage(data)
|
||||
if err != nil {
|
||||
t.Fatalf("ParsePage(%q) error = %v", data, err)
|
||||
}
|
||||
if listID != 42 || page != 3 {
|
||||
t.Fatalf("ParsePage(%q) = %d, %d", data, listID, page)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPageCallbackRejectsBadData(t *testing.T) {
|
||||
for _, data := range []string{
|
||||
"v1:page",
|
||||
"v1:page:42",
|
||||
"v1:page:0:1",
|
||||
"v1:page:42:0",
|
||||
"v1:page:42:abc",
|
||||
"v1:page:42:1:extra",
|
||||
"page:42:1",
|
||||
} {
|
||||
if _, _, err := ParsePage(data); err == nil {
|
||||
t.Fatalf("ParsePage(%q) accepted invalid data", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareCallbackRoundTrips(t *testing.T) {
|
||||
for _, pending := range []bool{false, true} {
|
||||
ref := ShareRef{ListID: 42, ShareID: 7, Pending: pending}
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
data string
|
||||
action CallbackAction
|
||||
}{
|
||||
{name: "view", data: ShareViewData(ref), action: ActionShareView},
|
||||
{name: "revoke", data: ShareRevokeData(ref), action: ActionShareRevoke},
|
||||
{name: "revoke yes", data: ShareRevokeYesData(ref), action: ActionShareRevokeYes},
|
||||
} {
|
||||
got, err := ParseShareRef(tt.data, tt.action)
|
||||
if err != nil {
|
||||
t.Fatalf("%s ParseShareRef(%q) error = %v", tt.name, tt.data, err)
|
||||
}
|
||||
if got != ref {
|
||||
t.Fatalf("%s ParseShareRef(%q) = %+v, want %+v", tt.name, tt.data, got, ref)
|
||||
}
|
||||
}
|
||||
|
||||
got, role, err := ParseShareSet(ShareSetData(ref, domain.RoleReader))
|
||||
if err != nil || role != domain.RoleReader || got != ref {
|
||||
t.Fatalf("ParseShareSet() = %+v, %q, %v", got, role, err)
|
||||
}
|
||||
}
|
||||
|
||||
listID, role, err := ParseShareRole(ShareRoleData(42, domain.RoleEditor))
|
||||
if err != nil || listID != 42 || role != domain.RoleEditor {
|
||||
t.Fatalf("ParseShareRole() = %d, %q, %v", listID, role, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestShareCallbackRejectsBadData(t *testing.T) {
|
||||
for _, data := range []string{
|
||||
"v1:share_role:42",
|
||||
"v1:share_role:0:editor",
|
||||
"v1:share_role:42:owner",
|
||||
"v1:share_view:42:u",
|
||||
"v1:share_view:42:x:7",
|
||||
"v1:share_view:42:u:0",
|
||||
"v1:share_set:42:u:7",
|
||||
"v1:share_set:42:u:7:owner",
|
||||
"v1:share_revoke:42:u",
|
||||
"share_view:42:u:7",
|
||||
} {
|
||||
if !rejectsShareData(data) {
|
||||
t.Fatalf("accepted invalid callback data %q", data)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func rejectsShareData(data string) bool {
|
||||
if _, _, err := ParseShareSet(data); err == nil {
|
||||
return false
|
||||
}
|
||||
if _, err := ParseShareRef(data, ActionShareView); err == nil {
|
||||
return false
|
||||
}
|
||||
if _, err := ParseShareRef(data, ActionShareRevoke); err == nil {
|
||||
return false
|
||||
}
|
||||
if _, _, err := ParseShareRole(data); err == nil {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func TestCallbackBuildersFitTelegramLimit(t *testing.T) {
|
||||
ref := ShareRef{ListID: 9223372036854775807, ShareID: 9223372036854775807}
|
||||
data := []string{
|
||||
NavLists, NavNew, NavHelp,
|
||||
ListData(1 << 62),
|
||||
RenameListData(1 << 62),
|
||||
DeleteListData(1 << 62),
|
||||
DeleteListYesData(1 << 62),
|
||||
SharesData(1 << 62),
|
||||
PageData(1<<62, 1<<30),
|
||||
ShareAddData(1 << 62),
|
||||
ShareRoleData(1<<62, domain.RoleEditor),
|
||||
ShareViewData(ref),
|
||||
ShareSetData(ref, domain.RoleEditor),
|
||||
ShareRevokeData(ref),
|
||||
ShareRevokeYesData(ref),
|
||||
}
|
||||
for _, item := range data {
|
||||
if len(item) > MaxCallbackDataLength {
|
||||
t.Fatalf("callback data %q is %d bytes, limit %d",
|
||||
item, len(item), MaxCallbackDataLength)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// MyListsLabel is both the reply-keyboard button text and the message text
|
||||
// Telegram sends when that button is pressed.
|
||||
const MyListsLabel = "My lists"
|
||||
|
||||
// MainKeyboard is the persistent keyboard shown below the input field. Reply
|
||||
// keyboards are chat-wide, so sending it once keeps the button available.
|
||||
func MainKeyboard() *telego.ReplyKeyboardMarkup {
|
||||
return &telego.ReplyKeyboardMarkup{
|
||||
Keyboard: [][]telego.KeyboardButton{
|
||||
{tu.KeyboardButton(MyListsLabel)},
|
||||
},
|
||||
IsPersistent: true,
|
||||
ResizeKeyboard: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package shared
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestMainKeyboardHasMyListsButton(t *testing.T) {
|
||||
keyboard := MainKeyboard()
|
||||
if !keyboard.IsPersistent {
|
||||
t.Fatal("MainKeyboard() is not persistent")
|
||||
}
|
||||
if !keyboard.ResizeKeyboard {
|
||||
t.Fatal("MainKeyboard() does not resize")
|
||||
}
|
||||
if len(keyboard.Keyboard) != 1 || len(keyboard.Keyboard[0]) != 1 {
|
||||
t.Fatalf("MainKeyboard() = %v, want a single row with one button", keyboard.Keyboard)
|
||||
}
|
||||
if got := keyboard.Keyboard[0][0].Text; got != MyListsLabel {
|
||||
t.Fatalf("button text = %q, want %q", got, MyListsLabel)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
)
|
||||
|
||||
// PendingHandler consumes free text for a feature after its prompt.
|
||||
type PendingHandler func(
|
||||
ctx *th.Context, user domain.User, pending service.Pending, message telego.Message,
|
||||
) error
|
||||
|
||||
// PendingRouter lets features own their prompt handlers without making the
|
||||
// root dispatcher know every feature.
|
||||
type PendingRouter struct {
|
||||
handlers map[service.PendingAction]PendingHandler
|
||||
}
|
||||
|
||||
func NewPendingRouter() *PendingRouter {
|
||||
return &PendingRouter{handlers: make(map[service.PendingAction]PendingHandler)}
|
||||
}
|
||||
|
||||
func (r *PendingRouter) Register(action service.PendingAction, handler PendingHandler) {
|
||||
r.handlers[action] = handler
|
||||
}
|
||||
|
||||
func (r *PendingRouter) Dispatch(
|
||||
ctx *th.Context, user domain.User, pending service.Pending, message telego.Message,
|
||||
) (bool, error) {
|
||||
handler, ok := r.handlers[pending.Action]
|
||||
if !ok {
|
||||
return false, nil
|
||||
}
|
||||
return true, handler(ctx, user, pending, message)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
"git.entityseven.com/rilaxik/pllist/internal/service"
|
||||
)
|
||||
|
||||
func TestPendingRouterDispatch(t *testing.T) {
|
||||
router := NewPendingRouter()
|
||||
called := false
|
||||
router.Register(service.PendingCreateList, func(
|
||||
_ *th.Context, _ domain.User, _ service.Pending, _ telego.Message,
|
||||
) error {
|
||||
called = true
|
||||
return nil
|
||||
})
|
||||
|
||||
handled, err := router.Dispatch(nil, domain.User{}, service.Pending{
|
||||
Action: service.PendingCreateList,
|
||||
}, telego.Message{})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if !handled || !called {
|
||||
t.Fatalf("Dispatch() handled = %v, called = %v", handled, called)
|
||||
}
|
||||
|
||||
handled, err = router.Dispatch(nil, domain.User{}, service.Pending{
|
||||
Action: "unknown",
|
||||
}, telego.Message{})
|
||||
if err != nil {
|
||||
t.Fatalf("Dispatch() error = %v", err)
|
||||
}
|
||||
if handled {
|
||||
t.Fatal("Dispatch() handled an unknown action")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
// Sender is the Telegram transport shared by all features.
|
||||
type Sender struct {
|
||||
api *telego.Bot
|
||||
}
|
||||
|
||||
func NewSender(api *telego.Bot) *Sender {
|
||||
return &Sender{api: api}
|
||||
}
|
||||
|
||||
func (s *Sender) Send(
|
||||
ctx context.Context, chatID int64, text string, keyboard telego.ReplyMarkup,
|
||||
) error {
|
||||
_, err := s.api.SendMessage(ctx, sendParams(chatID, text, keyboard))
|
||||
if err != nil {
|
||||
return fmt.Errorf("send message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Sender) EditOrSend(
|
||||
ctx context.Context, chatID int64, messageID int, text string, keyboard *telego.InlineKeyboardMarkup,
|
||||
) error {
|
||||
if messageID == 0 {
|
||||
return s.Send(ctx, chatID, text, keyboard)
|
||||
}
|
||||
_, err := s.api.EditMessageText(ctx, editParams(chatID, messageID, text, keyboard))
|
||||
if err != nil {
|
||||
return fmt.Errorf("edit message: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Only set reply_markup when a keyboard exists; Telegram rejects null.
|
||||
func sendParams(
|
||||
chatID int64, text string, keyboard telego.ReplyMarkup,
|
||||
) *telego.SendMessageParams {
|
||||
params := &telego.SendMessageParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
Text: FitMessage(text),
|
||||
ParseMode: telego.ModeHTML,
|
||||
}
|
||||
if keyboard != nil {
|
||||
params.ReplyMarkup = keyboard
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
func editParams(
|
||||
chatID int64, messageID int, text string, keyboard *telego.InlineKeyboardMarkup,
|
||||
) *telego.EditMessageTextParams {
|
||||
params := &telego.EditMessageTextParams{
|
||||
ChatID: tu.ID(chatID),
|
||||
MessageID: messageID,
|
||||
Text: FitMessage(text),
|
||||
ParseMode: telego.ModeHTML,
|
||||
}
|
||||
if keyboard != nil {
|
||||
params.ReplyMarkup = keyboard
|
||||
}
|
||||
return params
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
func TestSendParamsOmitNilKeyboard(t *testing.T) {
|
||||
raw, err := json.Marshal(sendParams(1, "hello", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "reply_markup") {
|
||||
t.Fatalf("send params contain reply_markup: %s", raw)
|
||||
}
|
||||
|
||||
keyboard := tu.InlineKeyboard(tu.InlineKeyboardRow(Button("Go", "go")))
|
||||
raw, err = json.Marshal(sendParams(1, "hello", keyboard))
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "reply_markup") {
|
||||
t.Fatalf("send params missing reply_markup: %s", raw)
|
||||
}
|
||||
|
||||
raw, err = json.Marshal(sendParams(1, "hello", MainKeyboard()))
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), `"keyboard"`) {
|
||||
t.Fatalf("send params missing reply keyboard: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditParamsOmitNilKeyboard(t *testing.T) {
|
||||
raw, err := json.Marshal(editParams(1, 2, "hello", nil))
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if strings.Contains(string(raw), "reply_markup") {
|
||||
t.Fatalf("edit params contain reply_markup: %s", raw)
|
||||
}
|
||||
|
||||
keyboard := &telego.InlineKeyboardMarkup{}
|
||||
raw, err = json.Marshal(editParams(1, 2, "hello", keyboard))
|
||||
if err != nil {
|
||||
t.Fatalf("Marshal() error = %v", err)
|
||||
}
|
||||
if !strings.Contains(string(raw), "reply_markup") {
|
||||
t.Fatalf("edit params missing reply_markup: %s", raw)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
func UserMessage(err error) string {
|
||||
var validation domain.ValidationError
|
||||
if errors.As(err, &validation) {
|
||||
return validation.Message
|
||||
}
|
||||
|
||||
switch {
|
||||
case errors.Is(err, domain.ErrNotFound):
|
||||
return "Not found or no access."
|
||||
case errors.Is(err, domain.ErrForbidden):
|
||||
return "You cannot do that."
|
||||
case errors.Is(err, domain.ErrConflict):
|
||||
return "This changed while you were looking. Try again."
|
||||
case errors.Is(err, domain.ErrInvalidInput):
|
||||
return "Invalid input."
|
||||
case errors.Is(err, ErrBadCallback):
|
||||
return "This button is out of date. Use /lists."
|
||||
default:
|
||||
return "Something went wrong. Try again."
|
||||
}
|
||||
}
|
||||
|
||||
// IsExpectedError reports whether an error carries a user-facing explanation
|
||||
// rather than an internal failure.
|
||||
func IsExpectedError(err error) bool {
|
||||
var validation domain.ValidationError
|
||||
if errors.As(err, &validation) {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, domain.ErrNotFound) ||
|
||||
errors.Is(err, domain.ErrForbidden) ||
|
||||
errors.Is(err, domain.ErrConflict) ||
|
||||
errors.Is(err, domain.ErrInvalidInput) ||
|
||||
errors.Is(err, ErrBadCallback)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
func TestUserMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
want string
|
||||
}{
|
||||
{name: "validation", err: domain.NewValidationError("Title is too long."), want: "Title is too long."},
|
||||
{name: "wrapped validation", err: errors.Join(errors.New("context"), domain.NewValidationError("Bad quantity.")), want: "Bad quantity."},
|
||||
{name: "not found", err: domain.ErrNotFound, want: "Not found or no access."},
|
||||
{name: "forbidden", err: domain.ErrForbidden, want: "You cannot do that."},
|
||||
{name: "conflict", err: domain.ErrConflict, want: "This changed while you were looking. Try again."},
|
||||
{name: "bad callback", err: ErrBadCallback, want: "This button is out of date. Use /lists."},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := UserMessage(tt.err); got != tt.want {
|
||||
t.Fatalf("UserMessage() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsExpectedError(t *testing.T) {
|
||||
expected := []error{
|
||||
domain.NewValidationError("bad"),
|
||||
domain.ErrNotFound,
|
||||
domain.ErrForbidden,
|
||||
domain.ErrConflict,
|
||||
domain.ErrInvalidInput,
|
||||
ErrBadCallback,
|
||||
}
|
||||
for _, err := range expected {
|
||||
if !IsExpectedError(err) {
|
||||
t.Fatalf("IsExpectedError(%v) = false, want true", err)
|
||||
}
|
||||
}
|
||||
if IsExpectedError(errors.New("database down")) {
|
||||
t.Fatal("IsExpectedError() = true for an unexpected error")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/mymmrac/telego"
|
||||
tu "github.com/mymmrac/telego/telegoutil"
|
||||
)
|
||||
|
||||
var escaper = strings.NewReplacer("&", "&", "<", "<", ">", ">")
|
||||
|
||||
func Button(text, data string) telego.InlineKeyboardButton {
|
||||
return tu.InlineKeyboardButton(text).WithCallbackData(data)
|
||||
}
|
||||
|
||||
func Escape(text string) string {
|
||||
return escaper.Replace(text)
|
||||
}
|
||||
|
||||
func Truncate(text string, max int) string {
|
||||
if utf8.RuneCountInString(text) <= max {
|
||||
return text
|
||||
}
|
||||
runes := []rune(text)
|
||||
return string(runes[:max-1]) + "…"
|
||||
}
|
||||
|
||||
// TelegramMessageLimit is Telegram's maximum text length in characters.
|
||||
const TelegramMessageLimit = 4096
|
||||
|
||||
// FitMessage truncates rendered HTML to the Telegram message limit without
|
||||
// cutting an entity or leaving a tag open.
|
||||
func FitMessage(text string) string {
|
||||
runes := []rune(text)
|
||||
if len(runes) <= TelegramMessageLimit {
|
||||
return text
|
||||
}
|
||||
|
||||
limit := TelegramMessageLimit - 1 // reserve the ellipsis
|
||||
cut := safeCut(runes, limit)
|
||||
for cut > 0 {
|
||||
closers := closingTags(runes[:cut])
|
||||
if cut+utf8.RuneCountInString(closers) <= limit {
|
||||
return string(runes[:cut]) + closers + "…"
|
||||
}
|
||||
cut = safeCut(runes, cut-1)
|
||||
}
|
||||
return "…"
|
||||
}
|
||||
|
||||
// safeCut returns the largest index up to limit that is not inside an HTML tag
|
||||
// or entity.
|
||||
func safeCut(runes []rune, limit int) int {
|
||||
if limit > len(runes) {
|
||||
limit = len(runes)
|
||||
}
|
||||
inTag := false
|
||||
entityStart := -1
|
||||
for i := 0; i < limit; i++ {
|
||||
char := runes[i]
|
||||
switch {
|
||||
case inTag:
|
||||
if char == '>' {
|
||||
inTag = false
|
||||
}
|
||||
case entityStart >= 0:
|
||||
if char == ';' {
|
||||
entityStart = -1
|
||||
}
|
||||
case char == '<':
|
||||
inTag = true
|
||||
case char == '&':
|
||||
entityStart = i
|
||||
}
|
||||
}
|
||||
if inTag {
|
||||
for i := limit - 1; i >= 0; i-- {
|
||||
if runes[i] == '<' {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
if entityStart >= 0 {
|
||||
return entityStart
|
||||
}
|
||||
return limit
|
||||
}
|
||||
|
||||
// closingTags returns the closing tags needed for any HTML left open by prefix.
|
||||
func closingTags(prefix []rune) string {
|
||||
var open []string
|
||||
for i := 0; i < len(prefix); i++ {
|
||||
if prefix[i] != '<' {
|
||||
continue
|
||||
}
|
||||
end := i + 1
|
||||
for end < len(prefix) && prefix[end] != '>' {
|
||||
end++
|
||||
}
|
||||
if end >= len(prefix) {
|
||||
break
|
||||
}
|
||||
raw := strings.TrimSpace(string(prefix[i+1 : end]))
|
||||
i = end
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(raw, "/") {
|
||||
name := strings.TrimSpace(raw[1:])
|
||||
if len(open) > 0 && open[len(open)-1] == name {
|
||||
open = open[:len(open)-1]
|
||||
}
|
||||
continue
|
||||
}
|
||||
name := raw
|
||||
if space := strings.IndexAny(name, " \t\n"); space >= 0 {
|
||||
name = name[:space]
|
||||
}
|
||||
if strings.HasSuffix(name, "/") || name == "br" {
|
||||
continue
|
||||
}
|
||||
open = append(open, name)
|
||||
}
|
||||
|
||||
var closers strings.Builder
|
||||
for i := len(open) - 1; i >= 0; i-- {
|
||||
closers.WriteString("</")
|
||||
closers.WriteString(open[i])
|
||||
closers.WriteByte('>')
|
||||
}
|
||||
return closers.String()
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
func TestEscape(t *testing.T) {
|
||||
if got := Escape(`<b>Milk & eggs</b>`); got != "<b>Milk & eggs</b>" {
|
||||
t.Fatalf("Escape() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitMessageLeavesShortText(t *testing.T) {
|
||||
text := "<b>Milk & eggs</b>"
|
||||
if got := FitMessage(text); got != text {
|
||||
t.Fatalf("FitMessage() = %q, want unchanged", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitMessageTruncatesPlainText(t *testing.T) {
|
||||
got := FitMessage(strings.Repeat("a", TelegramMessageLimit+100))
|
||||
if utf8.RuneCountInString(got) > TelegramMessageLimit {
|
||||
t.Fatalf("FitMessage() is %d runes, limit %d",
|
||||
utf8.RuneCountInString(got), TelegramMessageLimit)
|
||||
}
|
||||
if !strings.HasSuffix(got, "…") {
|
||||
t.Fatal("FitMessage() does not end with an ellipsis")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitMessageClosesOpenTags(t *testing.T) {
|
||||
got := FitMessage("<b>" + strings.Repeat("a", TelegramMessageLimit))
|
||||
if utf8.RuneCountInString(got) > TelegramMessageLimit {
|
||||
t.Fatalf("FitMessage() is %d runes, limit %d",
|
||||
utf8.RuneCountInString(got), TelegramMessageLimit)
|
||||
}
|
||||
if !strings.HasSuffix(got, "</b>…") {
|
||||
t.Fatalf("FitMessage() does not close <b>: %q", got[len(got)-20:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitMessageDoesNotSplitEntity(t *testing.T) {
|
||||
got := FitMessage(strings.Repeat("a", TelegramMessageLimit-2) + "&" + "tail")
|
||||
if utf8.RuneCountInString(got) > TelegramMessageLimit {
|
||||
t.Fatalf("FitMessage() is %d runes, limit %d",
|
||||
utf8.RuneCountInString(got), TelegramMessageLimit)
|
||||
}
|
||||
if strings.Contains(got, "&am…") || strings.HasSuffix(got, "&") {
|
||||
t.Fatalf("FitMessage() split an entity: %q", got[len(got)-10:])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFitMessageDoesNotSplitTag(t *testing.T) {
|
||||
got := FitMessage(strings.Repeat("a", TelegramMessageLimit-3) + "<b>tail</b>")
|
||||
if strings.HasSuffix(got, "<") || strings.HasSuffix(got, "<b") {
|
||||
t.Fatalf("FitMessage() split a tag: %q", got[len(got)-10:])
|
||||
}
|
||||
if utf8.RuneCountInString(got) > TelegramMessageLimit {
|
||||
t.Fatalf("FitMessage() is %d runes, limit %d",
|
||||
utf8.RuneCountInString(got), TelegramMessageLimit)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package shared
|
||||
|
||||
import (
|
||||
th "github.com/mymmrac/telego/telegohandler"
|
||||
|
||||
"git.entityseven.com/rilaxik/pllist/internal/domain"
|
||||
)
|
||||
|
||||
type userKey struct{}
|
||||
|
||||
func WithUser(ctx *th.Context, user domain.User) *th.Context {
|
||||
return ctx.WithValue(userKey{}, user)
|
||||
}
|
||||
|
||||
func UserFrom(ctx *th.Context) domain.User {
|
||||
user, _ := ctx.Value(userKey{}).(domain.User)
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package telegram
|
||||
|
||||
import "github.com/mymmrac/telego"
|
||||
|
||||
func updateFrom(update telego.Update) *telego.User {
|
||||
if update.Message != nil {
|
||||
return update.Message.From
|
||||
}
|
||||
if update.CallbackQuery != nil {
|
||||
return &update.CallbackQuery.From
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func updateChatID(update telego.Update) (int64, bool) {
|
||||
if update.Message != nil {
|
||||
return update.Message.Chat.ID, true
|
||||
}
|
||||
if update.CallbackQuery != nil && update.CallbackQuery.Message != nil {
|
||||
return update.CallbackQuery.Message.GetChat().ID, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func isPrivateChat(update telego.Update) bool {
|
||||
if update.Message != nil {
|
||||
return update.Message.Chat.Type == telego.ChatTypePrivate
|
||||
}
|
||||
if update.CallbackQuery != nil && update.CallbackQuery.Message != nil {
|
||||
return update.CallbackQuery.Message.GetChat().Type == telego.ChatTypePrivate
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Заблокувати користувача