Add Discord outbound posting.
CI / test (pull_request) Successful in 6m20s

This commit is contained in:
2026-08-29 01:13:44 -07:00
parent b8f1d88d6e
commit 06fcc16c02
16 changed files with 1037 additions and 2 deletions
+105
View File
@@ -0,0 +1,105 @@
package store
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"plumber/internal/store/sqlc"
)
// DiscordLink is the bot-owned mapping from a site post to a Discord message.
type DiscordLink struct {
PostID string
MessageID string
ThreadID string
CreatedAt string
}
// DiscordLinkStore is the mapping table used only by the Discord subscriber.
// It is not part of Store.
type DiscordLinkStore interface {
GetByPostID(ctx context.Context, postID string) (*DiscordLink, error)
GetByMessageID(ctx context.Context, messageID string) (*DiscordLink, error)
GetRootByThreadID(ctx context.Context, threadID string) (*DiscordLink, error)
Upsert(ctx context.Context, link DiscordLink) error
}
// DiscordLinks implements DiscordLinkStore against Postgres.
type DiscordLinks struct {
db *sql.DB
}
// NewDiscordLinks wraps db. It is independent of Store.
func NewDiscordLinks(db *sql.DB) *DiscordLinks {
return &DiscordLinks{db: db}
}
// GetByPostID returns the link for a site post.
func (d *DiscordLinks) GetByPostID(ctx context.Context, postID string) (*DiscordLink, error) {
if d == nil || d.db == nil {
return nil, fmt.Errorf("discord links: no database")
}
row, err := sqlc.New(d.db).GetDiscordPostLinkByPostID(ctx, strings.TrimSpace(postID))
if err != nil {
return nil, err
}
return discordLinkFromRow(row), nil
}
// GetByMessageID returns the link for a Discord message.
func (d *DiscordLinks) GetByMessageID(ctx context.Context, messageID string) (*DiscordLink, error) {
if d == nil || d.db == nil {
return nil, fmt.Errorf("discord links: no database")
}
row, err := sqlc.New(d.db).GetDiscordPostLinkByMessageID(ctx, strings.TrimSpace(messageID))
if err != nil {
return nil, err
}
return discordLinkFromRow(row), nil
}
// GetRootByThreadID returns the root link for a Discord thread.
func (d *DiscordLinks) GetRootByThreadID(ctx context.Context, threadID string) (*DiscordLink, error) {
if d == nil || d.db == nil {
return nil, fmt.Errorf("discord links: no database")
}
row, err := sqlc.New(d.db).GetDiscordPostLinkByThreadID(ctx, strings.TrimSpace(threadID))
if err != nil {
return nil, err
}
return discordLinkFromRow(row), nil
}
// Upsert inserts or replaces the Discord IDs for a post.
func (d *DiscordLinks) Upsert(ctx context.Context, link DiscordLink) error {
if d == nil || d.db == nil {
return fmt.Errorf("discord links: no database")
}
link.PostID = strings.TrimSpace(link.PostID)
link.MessageID = strings.TrimSpace(link.MessageID)
link.ThreadID = strings.TrimSpace(link.ThreadID)
if link.PostID == "" || link.MessageID == "" {
return fmt.Errorf("discord links: post and message ids are required")
}
if link.CreatedAt == "" {
link.CreatedAt = time.Now().UTC().Format(time.RFC3339Nano)
}
return sqlc.New(d.db).UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: link.PostID,
DiscordMessageID: link.MessageID,
DiscordThreadID: link.ThreadID,
CreatedAt: link.CreatedAt,
})
}
func discordLinkFromRow(row sqlc.DiscordPostLink) *DiscordLink {
return &DiscordLink{
PostID: row.PostID,
MessageID: row.DiscordMessageID,
ThreadID: row.DiscordThreadID,
CreatedAt: row.CreatedAt,
}
}
+20
View File
@@ -139,6 +139,25 @@ CREATE TABLE IF NOT EXISTS post_images (
return nil
}
func migrateDiscordPostLinks(ctx context.Context, exec execContext) error {
if _, err := exec.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS discord_post_links (
post_id TEXT PRIMARY KEY REFERENCES posts(id) ON DELETE CASCADE,
discord_message_id TEXT NOT NULL UNIQUE,
discord_thread_id TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL
)`); err != nil {
return fmt.Errorf("create discord_post_links: %w", err)
}
if _, err := exec.ExecContext(ctx, `
CREATE UNIQUE INDEX IF NOT EXISTS discord_post_links_thread_uidx
ON discord_post_links (discord_thread_id)
WHERE discord_thread_id <> ''`); err != nil {
return fmt.Errorf("discord_post_links_thread_uidx: %w", err)
}
return nil
}
func migratePostDate(ctx context.Context, exec execContext) error {
steps := []struct {
name string
@@ -331,6 +350,7 @@ CREATE TABLE IF NOT EXISTS schema_migrations (
{"008_post_author_index", migratePostAuthorIndex},
{"009_drop_legacy_post_tables", migrateDropLegacyPostTables},
{"010_post_images", migratePostImages},
{"011_discord_post_links", migrateDiscordPostLinks},
}
for _, m := range migrations {
if applied[m.version] {
+42
View File
@@ -81,6 +81,12 @@ CREATE TABLE users (
if err := migratePostImages(ctx, conn); err != nil {
t.Fatalf("post images migration is not idempotent: %v", err)
}
if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatal(err)
}
if err := migrateDiscordPostLinks(ctx, conn); err != nil {
t.Fatalf("discord post links migration is not idempotent: %v", err)
}
if _, err := conn.ExecContext(ctx, `
INSERT INTO users (id, name, role)
VALUES ('homeowner', 'Home Owner', 'user'), ('plumber', 'The Plumber', 'admin');
@@ -143,6 +149,42 @@ VALUES ('homeowner', 'root-1', 1);`); err != nil {
}); err == nil {
t.Fatal("fifth image position unexpectedly succeeded")
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "root-1",
DiscordMessageID: "msg-root",
DiscordThreadID: "thread-root",
CreatedAt: "2026-08-26T08:00:00Z",
}); err != nil {
t.Fatal(err)
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "reply-1",
DiscordMessageID: "msg-reply",
DiscordThreadID: "",
CreatedAt: "2026-08-26T09:00:00Z",
}); err != nil {
t.Fatal(err)
}
rootLink, err := imageQueries.GetDiscordPostLinkByPostID(ctx, "root-1")
if err != nil || rootLink.DiscordMessageID != "msg-root" || rootLink.DiscordThreadID != "thread-root" {
t.Fatalf("root discord link = %+v, %v", rootLink, err)
}
threadLink, err := imageQueries.GetDiscordPostLinkByThreadID(ctx, "thread-root")
if err != nil || threadLink.PostID != "root-1" {
t.Fatalf("thread discord link = %+v, %v", threadLink, err)
}
if err := imageQueries.UpsertDiscordPostLink(ctx, sqlc.UpsertDiscordPostLinkParams{
PostID: "reply-1",
DiscordMessageID: "msg-reply-2",
DiscordThreadID: "",
CreatedAt: "2026-08-26T09:01:00Z",
}); err != nil {
t.Fatal(err)
}
replyLink, err := imageQueries.GetDiscordPostLinkByPostID(ctx, "reply-1")
if err != nil || replyLink.DiscordMessageID != "msg-reply-2" || replyLink.DiscordThreadID != "" {
t.Fatalf("reply upsert = %+v, %v", replyLink, err)
}
var postVoteIndexCount int
if err := conn.QueryRowContext(ctx, `
SELECT count(*)
+100
View File
@@ -0,0 +1,100 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// source: discord_links.sql
package sqlc
import (
"context"
)
const getDiscordPostLinkByMessageID = `-- name: GetDiscordPostLinkByMessageID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_message_id = $1
`
func (q *Queries) GetDiscordPostLinkByMessageID(ctx context.Context, discordMessageID string) (DiscordPostLink, error) {
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByMessageID, discordMessageID)
var i DiscordPostLink
err := row.Scan(
&i.PostID,
&i.DiscordMessageID,
&i.DiscordThreadID,
&i.CreatedAt,
)
return i, err
}
const getDiscordPostLinkByPostID = `-- name: GetDiscordPostLinkByPostID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE post_id = $1
`
func (q *Queries) GetDiscordPostLinkByPostID(ctx context.Context, postID string) (DiscordPostLink, error) {
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByPostID, postID)
var i DiscordPostLink
err := row.Scan(
&i.PostID,
&i.DiscordMessageID,
&i.DiscordThreadID,
&i.CreatedAt,
)
return i, err
}
const getDiscordPostLinkByThreadID = `-- name: GetDiscordPostLinkByThreadID :one
SELECT post_id, discord_message_id, discord_thread_id, created_at
FROM discord_post_links
WHERE discord_thread_id = $1
AND discord_thread_id <> ''
`
func (q *Queries) GetDiscordPostLinkByThreadID(ctx context.Context, discordThreadID string) (DiscordPostLink, error) {
row := q.db.QueryRowContext(ctx, getDiscordPostLinkByThreadID, discordThreadID)
var i DiscordPostLink
err := row.Scan(
&i.PostID,
&i.DiscordMessageID,
&i.DiscordThreadID,
&i.CreatedAt,
)
return i, err
}
const upsertDiscordPostLink = `-- name: UpsertDiscordPostLink :exec
INSERT INTO discord_post_links (
post_id, discord_message_id, discord_thread_id, created_at
)
VALUES (
$1,
$2,
$3,
$4
)
ON CONFLICT (post_id) DO UPDATE SET
discord_message_id = EXCLUDED.discord_message_id,
discord_thread_id = CASE
WHEN EXCLUDED.discord_thread_id <> '' THEN EXCLUDED.discord_thread_id
ELSE discord_post_links.discord_thread_id
END
`
type UpsertDiscordPostLinkParams struct {
PostID string
DiscordMessageID string
DiscordThreadID string
CreatedAt string
}
func (q *Queries) UpsertDiscordPostLink(ctx context.Context, arg UpsertDiscordPostLinkParams) error {
_, err := q.db.ExecContext(ctx, upsertDiscordPostLink,
arg.PostID,
arg.DiscordMessageID,
arg.DiscordThreadID,
arg.CreatedAt,
)
return err
}
+7
View File
@@ -9,6 +9,13 @@ import (
"time"
)
type DiscordPostLink struct {
PostID string
DiscordMessageID string
DiscordThreadID string
CreatedAt string
}
type Post struct {
ID string
ParentID sql.NullString