Compare commits

..
9 Commits
Author SHA1 Message Date
rilaxik 0b716e8f25 feat: roadmap polish
ci / test (push) Successful in 1m29s
2026-11-08 03:52:49 +00:00
rilaxik ac8049a88a feat: roadmap M5 2026-11-08 03:46:11 +00:00
rilaxik 39ced0082d feat: roadmap M4 2026-11-08 03:05:46 +00:00
rilaxik 5bfd69cbb6 ci: local deploy changes 2026-11-07 21:49:44 +00:00
rilaxik 24f35c4348 feat: roadmap M3 2026-11-07 21:15:39 +00:00
rilaxik 3ea2b751a0 feat: roadmap M2 2026-11-07 20:55:19 +00:00
rilaxik c7684893bb feat: roadmap M1 2026-11-07 19:18:28 +00:00
rilaxik a283a9c454 feat: roadmap M1 2026-11-07 01:35:12 +00:00
rilaxik d06ec80614 feat(config,database): scaffold config + db 2026-11-06 18:14:19 +00:00
93 changed files with 7259 additions and 34 deletions

No files matched your search

+9 -3
View File
@@ -1,4 +1,10 @@
POSTGRES_USER=pllist
POSTGRES_PASSWORD=pllist
POSTGRES_DB=pllist
POSTGRES_HOST=0.0.0.0
TELEGRAM_BOT_TOKEN= TELEGRAM_BOT_TOKEN=
POSTGRES_USER= BOT_OWNER_ID=
POSTGRES_PASSWORD= AUTO_MIGRATE=true
POSTGRES_DB=
BOT_OWNER_ID=DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:5432/${POSTGRES_DB}?sslmode=disable
+15
View File
@@ -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 ./...
+1
View File
@@ -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 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 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. 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. The codebase stays small but extensible.
- Architecture: `docs/architecture.md` - Architecture: `docs/architecture.md`
+97 -1
View File
@@ -1,3 +1,99 @@
package main 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
}
+3 -3
View File
@@ -5,11 +5,12 @@ services:
build: build:
context: . context: .
dockerfile: dockerfile dockerfile: dockerfile
container_name: pllist-bot pull_policy: build
restart: always restart: always
environment: environment:
- DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable - DATABASE_URL=postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable
- TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN} - TELEGRAM_BOT_TOKEN=${TELEGRAM_BOT_TOKEN}
- BOT_OWNER_ID=${BOT_OWNER_ID}
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
@@ -18,7 +19,6 @@ services:
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: pllist-postgres
restart: always restart: always
environment: environment:
POSTGRES_USER: ${POSTGRES_USER} POSTGRES_USER: ${POSTGRES_USER}
@@ -39,4 +39,4 @@ volumes:
networks: networks:
bot-net: bot-net:
driver: bridge external: true
+10 -6
View File
@@ -1,13 +1,17 @@
# Build stage # Build stage
FROM golang:1.27-alpine AS builder FROM golang:1.27-alpine AS builder
WORKDIR /app WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY . . 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 # Run stage
FROM alpine:latest FROM alpine:3.22
WORKDIR /root/ RUN apk add --no-cache ca-certificates \
COPY --from=builder /bot . && addgroup -S app \
CMD ["./bot"] && adduser -S -G app -h /app app
WORKDIR /app
COPY --from=builder --chown=app:app /out/bot /app/bot
USER app
ENTRYPOINT ["/app/bot"]
+37 -5
View File
@@ -30,7 +30,7 @@ internal/domain/ entities, enums, invariants, domain errors
internal/service/ use cases: users, lists, items, access, sessions internal/service/ use cases: users, lists, items, access, sessions
internal/repository/ GORM repository implementations internal/repository/ GORM repository implementations
internal/database/ connection setup, pool tuning, migrations 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 internal/events/ publisher interface and no-op implementation
docs/ architecture, infrastructure, plan, notes docs/ architecture, infrastructure, plan, notes
``` ```
@@ -43,9 +43,36 @@ docs/ architecture, infrastructure, plan, notes
| `service` | Use cases and authorization. Owns transactions. Declares repository interfaces. | | `service` | Use cases and authorization. Owns transactions. Declares repository interfaces. |
| `repository` | GORM models and queries. Implements service interfaces. | | `repository` | GORM models and queries. Implements service interfaces. |
| `database` | Opens the pool, sets limits, exposes migration entry point. | | `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. | | `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 ## Layering
```text ```text
@@ -74,7 +101,9 @@ size.
## Domain summary ## 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. - `lists`: owned by exactly one user.
- `list_shares`: grants a user `reader` or `editor` access to a list. - `list_shares`: grants a user `reader` or `editor` access to a list.
- `items`: belong to a list, have `status` (`open`/`done`), `urgency` - `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 - `pending_shares`: shares granted by username before that user contacts the
bot. bot.
One `Authorize(ctx, userID, listID, action)` call is the source of truth for Bot access is checked once per update before a user row is ensured. The owner
access. Handlers call it once per update. 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 Schema, access matrix, callback format, and use-case details live in
`docs/temp-notes.md`. `docs/temp-notes.md`.
+6 -4
View File
@@ -40,7 +40,9 @@ Dokploy injects these into the bot container:
| Variable | Required | Purpose | | Variable | Required | Purpose |
| --- | --- | --- | | --- | --- | --- |
| `TELEGRAM_BOT_TOKEN` | yes | Bot token from BotFather | | `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` | | `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_USER` | compose | Database role for local Compose |
| `POSTGRES_PASSWORD` | compose | Database password for local Compose | | `POSTGRES_PASSWORD` | compose | Database password for local Compose |
| `POSTGRES_DB` | compose | Database name for local Compose | | `POSTGRES_DB` | compose | Database name for local Compose |
@@ -71,8 +73,8 @@ Use a separate bot token for development.
## Image ## Image
`dockerfile` is a multi-stage build: compile a static binary, then run it on `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 Alpine as an unprivileged `app` user. It keeps `ca-certificates` installed
Telegram API over TLS. because the bot calls the Telegram API over TLS.
```sh ```sh
docker build -t pllist-bot . docker build -t pllist-bot .
@@ -108,8 +110,8 @@ jobs:
3. Add an application connected to the Gitea repository. 3. Add an application connected to the Gitea repository.
4. Select the Dockerfile build type and set the build context to the repository 4. Select the Dockerfile build type and set the build context to the repository
root. root.
5. Set `TELEGRAM_BOT_TOKEN` and `DATABASE_URL` in the application environment. 5. Set `TELEGRAM_BOT_TOKEN`, `BOT_OWNER_ID`, and `DATABASE_URL` in the
Use the PostgreSQL service host from step 2. application environment. Use the PostgreSQL service host from step 2.
6. Set replicas to `1`. Long polling does not support multiple replicas. 6. Set replicas to `1`. Long polling does not support multiple replicas.
7. Deploy manually. Dokploy pulls the repository, builds the image, and starts 7. Deploy manually. Dokploy pulls the repository, builds the image, and starts
the container. the container.
+23 -12
View File
@@ -11,8 +11,8 @@
## M1 — Users and lists ## M1 — Users and lists
- Register or refresh a user on `/start`. - Ensure a user row on every allowed update; store only the Telegram ID.
- Commands: `/start`, `/help`, `/lists`, `/new`, `/cancel`, `/id`. - Commands: `/start`, `/help`, `/lists`, `/new`, `/cancel`.
- Create, rename, and soft-delete a list. Owner only for rename and delete. - Create, rename, and soft-delete a list. Owner only for rename and delete.
- List index with owned and shared lists. - List index with owned and shared lists.
@@ -24,16 +24,29 @@
- Reserve and release with a conditional update. - Reserve and release with a conditional update.
- List detail view with pagination and role-appropriate buttons. - 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. - Require `BOT_OWNER_ID`; the bot owner always has access.
- Pending shares for usernames that cannot be resolved yet. - Every other user needs an explicit grant. Deny all updates from users without
- Resolve pending shares when the user contacts the bot. 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. - 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. - Confirmation step for destructive actions.
- HTML escaping helper and Telegram length limits. - HTML escaping helper and Telegram length limits.
- Error mapping from domain errors to user messages. - Error mapping from domain errors to user messages.
@@ -41,12 +54,10 @@
## Before production ## Before production
- Add `ca-certificates` and a non-root user to the runtime image.
- Add versioned SQL migrations; stop relying on GORM automigrate. - Add versioned SQL migrations; stop relying on GORM automigrate.
- Add unit tests for domain transitions and the access matrix. - Add PostgreSQL-backed tests for the conditional item transitions (done,
- Add the Gitea Actions workflow. reopen, reserve, release) in the repository.
- Enable PostgreSQL backups in Dokploy. - Enable PostgreSQL backups in Dokploy.
- Add `429 retry_after` handling.
## Later ## Later
+61
View File
@@ -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
}
+91
View File
@@ -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")
}
})
}
+52
View File
@@ -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,
},
)
}
+23
View File
@@ -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}
}
+38
View File
@@ -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 }
+73
View File
@@ -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])
}
}
})
}
}
+55
View File
@@ -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
}
}
+13
View File
@@ -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
}
+10
View File
@@ -0,0 +1,10 @@
package domain
import "time"
type User struct {
ID int64
TelegramID int64
CreatedAt time.Time
UpdatedAt time.Time
}
+142
View File
@@ -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
}
+213
View File
@@ -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,
}
}
+105
View File
@@ -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,
}
}
+90
View File
@@ -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" }
+298
View File
@@ -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
}
+54
View File
@@ -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,
}
}
+53
View File
@@ -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
}
+91
View File
@@ -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)
}
})
}
}
+168
View File
@@ -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), "@"))
}
+270
View File
@@ -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)
}
})
}
}
+250
View File
@@ -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
}
}
+263
View File
@@ -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)
}
}
+152
View File
@@ -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)
}
})
}
}
+73
View File
@@ -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
}
+33
View File
@@ -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)
}
})
}
}
+84
View File
@@ -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)
}
+53
View File
@@ -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")
}
}
+257
View File
@@ -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
}
+343
View File
@@ -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)
}
}
+23
View File
@@ -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)
}
+89
View File
@@ -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)
}
+38
View File
@@ -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)
}
}
+5
View File
@@ -0,0 +1,5 @@
package access
const (
ownerOnlyText = "Only the bot owner can do that."
)
+111
View File
@@ -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
}
+29
View File
@@ -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,
}
}
+72
View File
@@ -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
}
+46
View File
@@ -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)
}
+44
View File
@@ -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)
}
}
}
+203
View File
@@ -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)
}
+74
View File
@@ -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)
}
+141
View File
@@ -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
}
+78
View File
@@ -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
}
+67
View File
@@ -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)
}
+122
View File
@@ -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 "🟡"
}
}
+189
View File
@@ -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)
}
}
}
+36
View File
@@ -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))
}
+58
View File
@@ -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))
}
+22
View File
@@ -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)
}
+52
View File
@@ -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)
}
+32
View File
@@ -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)),
),
)
}
+37
View File
@@ -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)
}
+32
View File
@@ -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()
}
+11
View File
@@ -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))
}
+39
View File
@@ -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()
}
}
+56
View File
@@ -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))
}
+29
View File
@@ -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 &lt;username or id&gt; - grant bot access
/kick &lt;username or id&gt; - revoke bot access`
)
func helpText(isOwner bool) string {
if isOwner {
return baseHelpText + ownerHelpText
}
return baseHelpText
}
+27
View File
@@ -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)
}
}
}
+11
View File
@@ -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."
)
+103
View File
@@ -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)
}
}
+50
View File
@@ -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")
}
}
+17
View File
@@ -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)
}
+93
View File
@@ -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
}
+102
View File
@@ -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)
}
+42
View File
@@ -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)
}
+76
View File
@@ -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)),
),
)
}
+31
View File
@@ -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))
}
+78
View File
@@ -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"
}
+51
View File
@@ -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)
}
}
}
+25
View File
@@ -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))
}
+305
View File
@@ -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()
}
+190
View File
@@ -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)
}
}
}
+22
View File
@@ -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,
}
}
+19
View File
@@ -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)
}
}
+38
View File
@@ -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)
}
+42
View File
@@ -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")
}
}
+71
View File
@@ -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
}
+56
View File
@@ -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)
}
}
+43
View File
@@ -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)
}
+50
View File
@@ -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")
}
}
+134
View File
@@ -0,0 +1,134 @@
package shared
import (
"strings"
"unicode/utf8"
"github.com/mymmrac/telego"
tu "github.com/mymmrac/telego/telegoutil"
)
var escaper = strings.NewReplacer("&", "&amp;", "<", "&lt;", ">", "&gt;")
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()
}
+64
View File
@@ -0,0 +1,64 @@
package shared
import (
"strings"
"testing"
"unicode/utf8"
)
func TestEscape(t *testing.T) {
if got := Escape(`<b>Milk & eggs</b>`); got != "&lt;b&gt;Milk &amp; eggs&lt;/b&gt;" {
t.Fatalf("Escape() = %q", got)
}
}
func TestFitMessageLeavesShortText(t *testing.T) {
text := "<b>Milk &amp; 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) + "&amp;" + "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)
}
}
+18
View File
@@ -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
}
+33
View File
@@ -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
}