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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 28 additions & 12 deletions cmd/bot/configure.go
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,21 @@ type PostgresConfig struct {
PasswordFile string `env:"PASSWORD_FILE" env-default:""`
}

type DigikeeperLogConfig struct {
Enabled bool `env:"ENABLED"`
BaseURL string `env:"BASE_URL" env-default:"http://localhost:8080"`
Timeout time.Duration `env:"TIMEOUT" env-default:"3s"`
MaxRetries int `env:"MAX_RETRIES" env-default:"3"`
ClientID string `env:"CLIENT_ID" env-default:"digikeeper-bot"`
Token SecretValue `env:"TOKEN" env-default:""`
TokenFile string `env:"TOKEN_FILE" env-default:""`
}

type Config struct {
Common CommonConfig `yaml:"common"`
Telegram TelegramConfig `yaml:"telegram" env-prefix:"TELEGRAM_"`
Postgres PostgresConfig `yaml:"postgres" env-prefix:"POSTGRES_"`
Common CommonConfig `yaml:"common"`
Telegram TelegramConfig `yaml:"telegram" env-prefix:"TELEGRAM_"`
Postgres PostgresConfig `yaml:"postgres" env-prefix:"POSTGRES_"`
DigikeeperLog DigikeeperLogConfig `yaml:"digikeeperlog" env-prefix:"DIGIKEEPER_LOG_"`
}

func (c *Config) IsDevEnv() bool {
Expand Down Expand Up @@ -99,18 +110,23 @@ func configure() Config {
log.Fatalf("Failed to read bot token: %v", err)
}

if !cfg.Postgres.Enabled {
return cfg
}
if cfg.Postgres.Enabled {
err = cfg.Postgres.User.LoadFromFile(cfg.Postgres.UserFile)
if err != nil {
log.Fatalf("Failed to read postgres user: %v", err)
}

err = cfg.Postgres.User.LoadFromFile(cfg.Postgres.UserFile)
if err != nil {
log.Fatalf("Failed to read postgres user: %v", err)
err = cfg.Postgres.Password.LoadFromFile(cfg.Postgres.PasswordFile)
if err != nil {
log.Fatalf("Failed to read postgres password: %v", err)
}
}

err = cfg.Postgres.Password.LoadFromFile(cfg.Postgres.PasswordFile)
if err != nil {
log.Fatalf("Failed to read postgres password: %v", err)
if cfg.DigikeeperLog.Enabled && cfg.DigikeeperLog.TokenFile != "" {
err = cfg.DigikeeperLog.Token.LoadFromFile(cfg.DigikeeperLog.TokenFile)
if err != nil {
log.Fatalf("Failed to read digikeeper-log token: %v", err)
}
}

return cfg
Expand Down
38 changes: 37 additions & 1 deletion cmd/bot/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ import (
th "github.com/mymmrac/telego/telegohandler"

cmdh "github.com/gitrus/digikeeper-bot/internal/cmd_handler"
"github.com/gitrus/digikeeper-bot/pkg/journal"
session "github.com/gitrus/digikeeper-bot/pkg/sessionmanager"
cmdrouter "github.com/gitrus/digikeeper-bot/pkg/telego_commandrouter"
tm "github.com/gitrus/digikeeper-bot/pkg/telego_middleware"
session "github.com/gitrus/digikeeper-bot/pkg/sessionmanager"
)

func main() {
Expand All @@ -18,6 +19,19 @@ func main() {

ctx := context.Background()

var (
events *journal.Client
err error
)
if config.DigikeeperLog.Enabled {
events, err = initJournal(ctx, config, logger)
if err != nil {
logger.ErrorContext(ctx, "Failed to init journal client", "error", err)
return
}
defer func() { _ = events.Close() }()
}

bot, updates, err := initBot(ctx, config)
if err != nil {
logger.ErrorContext(ctx, "Failed to init bot: %v", "error", err)
Expand Down Expand Up @@ -48,6 +62,15 @@ func main() {

cmdHandlerGroup.BindCommandsToHandler(bh)

// Plain text messages (non-command) go to the note-submission handler so
// that users in the "add" state can finish the /add flow by typing the
// note contents. Registered after BindCommandsToHandler so command
// predicates match first.
bh.Handle(cmdh.HandleAddNoteText(usm, events),
th.AnyMessageWithText(),
th.Not(th.AnyCommand()),
)

logger.Info("CmdHandlerGroup", "group", cmdHandlerGroup)

logger.Info("Starting bot ...")
Expand All @@ -57,3 +80,16 @@ func main() {
return
}
}

// initJournal constructs the journal client from the bot config. Callers
// should only invoke it when DigikeeperLog.Enabled is true.
func initJournal(ctx context.Context, cfg Config, logger *slog.Logger) (*journal.Client, error) {
jcfg := journal.Config{
BaseURL: cfg.DigikeeperLog.BaseURL,
Timeout: cfg.DigikeeperLog.Timeout,
MaxRetries: cfg.DigikeeperLog.MaxRetries,
ClientID: cfg.DigikeeperLog.ClientID,
Token: cfg.DigikeeperLog.Token.String(),
}
return journal.New(ctx, jcfg, journal.WithLogger(logger))
}
8 changes: 7 additions & 1 deletion deployment/.env.template
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
# Bot configuration
TELEGRAM_BOT_PUBLIC_URL=your_public_url_or_localhost

# digikeeper-log event-journal client (disabled by default)
# DIGIKEEPER_LOG_ENABLED=true
# DIGIKEEPER_LOG_BASE_URL=http://digikeeper-log:8080
# DIGIKEEPER_LOG_TOKEN_FILE=/run/secrets/digikeeper_log_token

# Note: Sensitive credentials are stored in the ./secrets/ directory
# ./secrets/telegram_token.txt - Your Telegram bot token
# ./secrets/db_user.txt - Database username
# ./secrets/db_password.txt - Database password
# ./secrets/db_password.txt - Database password
# ./secrets/digikeeper_log_token - Bearer token for digikeeper-log (optional)
6 changes: 6 additions & 0 deletions deployment/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,12 @@ services:
- POSTGRES_USER_FILE=/run/secrets/db_user
- POSTGRES_PASSWORD_FILE=/run/secrets/db_password
- POSTGRES_DB=digikeeper
- DIGIKEEPER_LOG_ENABLED=false
- DIGIKEEPER_LOG_BASE_URL=http://digikeeper-log:8080
- DIGIKEEPER_LOG_TIMEOUT=3s
- DIGIKEEPER_LOG_MAX_RETRIES=3
- DIGIKEEPER_LOG_CLIENT_ID=digikeeper-bot
# DIGIKEEPER_LOG_TOKEN_FILE=/run/secrets/digikeeper_log_token
ports:
- target: 8081
published: 8081
Expand Down
96 changes: 92 additions & 4 deletions internal/cmd_handler/add.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,17 @@ import (
th "github.com/mymmrac/telego/telegohandler"
tu "github.com/mymmrac/telego/telegoutil"

"github.com/gitrus/digikeeper-bot/pkg/journal"
session "github.com/gitrus/digikeeper-bot/pkg/sessionmanager"
)

// stateAwaitingNote marks a user session that entered /add and is now
// expected to send the note contents as the next plain-text message.
const stateAwaitingNote = "add:awaiting_note"

// HandleAdd starts the /add flow: it transitions the user's session to
// "awaiting note" and prompts them for the note contents. The actual note
// persistence happens in HandleAddNoteText when the user replies.
func HandleAdd(usm session.UserSessionManager[*session.SimpleUserSession]) th.Handler {
return func(ctx *th.Context, update telego.Update) error {
slog.InfoContext(ctx.Context(), "Receive /add")
Expand All @@ -23,11 +31,11 @@ func HandleAdd(usm session.UserSessionManager[*session.SimpleUserSession]) th.Ha
_, err = usm.Set(
ctx,
userID,
&session.SimpleUserSession{UserID: userID, State: "add", Version: state.Version + 1},
&session.SimpleUserSession{UserID: userID, State: stateAwaitingNote, Version: state.Version + 1},
state.Version,
)
if err != nil {
slog.ErrorContext(ctx.Context(), "Failed to set state")
slog.ErrorContext(ctx.Context(), "Failed to set state", "error", err)

chatId := tu.ID(update.Message.Chat.ID)
_, err = ctx.Bot().SendMessage(ctx, tu.Message(
Expand All @@ -37,7 +45,87 @@ func HandleAdd(usm session.UserSessionManager[*session.SimpleUserSession]) th.Ha
return err
}

slog.InfoContext(ctx.Context(), "Set state", slog.String("state", state.State))
return nil
chatID := tu.ID(update.Message.Chat.ID)
_, err = ctx.Bot().SendMessage(ctx, tu.Message(
chatID,
"What should I note down? Send the text of the note, or /cancel to abort.",
))
return err
}
}

// HandleAddNoteText catches the next plain-text message from a user whose
// session is in stateAwaitingNote, persists it as a note.added journal event
// on digikeeper-log, and clears the state. Users not in the awaiting-note
// state are passed through via ctx.Next so other handlers can match.
//
// journal may be nil when the journal client is disabled in config; in that
// case the text is ignored with a user-visible notice instead of being lost.
func HandleAddNoteText(
usm session.UserSessionManager[*session.SimpleUserSession],
events *journal.Client,
) th.Handler {
return func(ctx *th.Context, update telego.Update) error {
if update.Message == nil || update.Message.From == nil {
return ctx.Next(update)
}
userID := update.Message.From.ID

state, err := usm.Fetch(ctx, userID)
if err != nil || state.State != stateAwaitingNote {
return ctx.Next(update)
}

chatID := tu.ID(update.Message.Chat.ID)

if events == nil {
slog.WarnContext(ctx.Context(), "journal client disabled, note dropped")
_, sendErr := ctx.Bot().SendMessage(ctx, tu.Message(
chatID,
"Note storage isn't configured right now — please try again later.",
))
if dropErr := usm.DropActive(ctx, userID); dropErr != nil {
slog.WarnContext(ctx.Context(), "Failed to drop session", "error", dropErr)
}
return sendErr
}

note := update.Message.Text
if note == "" {
_, err = ctx.Bot().SendMessage(ctx, tu.Message(
chatID,
"Empty note — please send the note text, or /cancel to abort.",
))
return err
}

res, err := events.Append(ctx.Context(), journal.NewNoteAddedEvent(userID, note))
if err != nil {
slog.ErrorContext(ctx.Context(), "Failed to append note event", "error", err)
_, sendErr := ctx.Bot().SendMessage(ctx, tu.Message(
chatID,
"I couldn't save the note right now. Please try again.",
))
if sendErr != nil {
return sendErr
}
return err
}

slog.InfoContext(ctx.Context(), "Note added",
slog.String("entry_id", res.ID),
slog.String("request_id", res.RequestID),
slog.Bool("indexed", res.Indexed),
)

if dropErr := usm.DropActive(ctx, userID); dropErr != nil {
slog.WarnContext(ctx.Context(), "Failed to drop session after note add", "error", dropErr)
}

_, err = ctx.Bot().SendMessage(ctx, tu.Message(
chatID,
"Noted. ✔",
))
return err
}
}
Loading