From 0bad7cd5baf6eae0070dbefd5afea6b873e2580c Mon Sep 17 00:00:00 2001 From: dhawos Date: Sat, 27 Jun 2026 20:33:48 +0200 Subject: [PATCH] add test dataset and first feed logic --- .env.dev | 1 + Taskfile.yml | 3 -- cmd/feedv2/main.go | 12 +++-- cmd/root.go | 2 + cmd/seed/main.go | 40 +++++++++++++++ feeds/lille.go | 46 ++++++++++++++++++ feeds/lille.png | Bin 0 -> 45100 bytes feeds/types.go | 14 +++--- flake.nix | 1 + indexer/elasticsearch.go | 8 +-- indexer/firehose.go | 2 +- indexer/seed.go | 37 ++++++++++++++ indexer/test_data.json | 102 +++++++++++++++++++++++++++++++++++++++ server/controller.go | 83 +++++++++++++++++++++++++++---- 14 files changed, 323 insertions(+), 28 deletions(-) create mode 100644 cmd/seed/main.go create mode 100644 feeds/lille.go create mode 100644 feeds/lille.png create mode 100644 indexer/seed.go create mode 100644 indexer/test_data.json diff --git a/.env.dev b/.env.dev index 12a6431..70a5e37 100644 --- a/.env.dev +++ b/.env.dev @@ -1,4 +1,5 @@ BSKYFEEDCTL_FIREHOSE="relay1.eurosky.network" +BSKYFEEDCTL_FEED_OWNERDID="at://did:plc:ryyjyoqbea5zmanqtutkbc4o" BSKYFEEDCTL_ELASTICSEARCH_HOST="localhost" BSKYFEEDCTL_ELASTICSEARCH_PORT="9200" BSKYFEEDCTL_ELASTICSEARCH_USERNAME="elastic" diff --git a/Taskfile.yml b/Taskfile.yml index ecb220a..03a6a28 100644 --- a/Taskfile.yml +++ b/Taskfile.yml @@ -3,9 +3,6 @@ version: "3" tasks: - psql-tap: - cmds: - - psql postgresql://tap:tap@localhost:5432/tap dev: cmds: - docker-compose up -d diff --git a/cmd/feedv2/main.go b/cmd/feedv2/main.go index 4dcceff..4a6e8db 100644 --- a/cmd/feedv2/main.go +++ b/cmd/feedv2/main.go @@ -4,6 +4,7 @@ import ( "fmt" "tangled.org/dhawos.dev/bskyfeedctl/config" + "tangled.org/dhawos.dev/bskyfeedctl/indexer" "tangled.org/dhawos.dev/bskyfeedctl/server" "github.com/spf13/cobra" @@ -11,8 +12,9 @@ import ( ) type cmdConfig struct { - Server server.ServerConfig - Feed server.FeedConfig + Server server.ServerConfig + Feed server.FeedConfig + Elasticsearch indexer.ElasticsearchConfig } var Cmd = &cobra.Command{ @@ -23,7 +25,11 @@ var Cmd = &cobra.Command{ if err := config.Loader.Unmarshal(&cmdConfig, viper.DecodeHook(config.AtURIHook())); err != nil { return fmt.Errorf("config: unable to decode into struct: %w", err) } - bfg := server.NewBlueskyFeedGenerator(cmdConfig.Server, cmdConfig.Feed) + bfg, err := server.NewBlueskyFeedGenerator(cmdConfig.Server, cmdConfig.Feed, cmdConfig.Elasticsearch) + if err != nil { + return fmt.Errorf("initializing bluesky feed generator: %w", err) + } + return bfg.Start() }, } diff --git a/cmd/root.go b/cmd/root.go index d505f07..cb1b8dc 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,11 +4,13 @@ import ( "github.com/spf13/cobra" "tangled.org/dhawos.dev/bskyfeedctl/cmd/feedv2" "tangled.org/dhawos.dev/bskyfeedctl/cmd/indexerv2" + "tangled.org/dhawos.dev/bskyfeedctl/cmd/seed" ) func init() { RootCmd.AddCommand(indexerv2.Cmd) RootCmd.AddCommand(feedv2.Cmd) + RootCmd.AddCommand(seed.Cmd) } var RootCmd = &cobra.Command{ diff --git a/cmd/seed/main.go b/cmd/seed/main.go new file mode 100644 index 0000000..e7f7cdb --- /dev/null +++ b/cmd/seed/main.go @@ -0,0 +1,40 @@ +package seed + +import ( + "fmt" + + "tangled.org/dhawos.dev/bskyfeedctl/config" + "tangled.org/dhawos.dev/bskyfeedctl/indexer" + + "github.com/spf13/cobra" +) + +type SeederConfig struct { + Elasticsearch indexer.ElasticsearchConfig +} + +var Cmd = &cobra.Command{ + Use: "seeder", + Short: "Commands for seeding fake data into the search engine", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + var seederConfig SeederConfig + + if err := config.Loader.Unmarshal(&seederConfig); err != nil { + return fmt.Errorf("unable to decode config into struct: %w", err) + } + esClient, err := indexer.NewElasticTypedClient(seederConfig.Elasticsearch) + if err != nil { + return fmt.Errorf("initializing elatticsearch client: %w", err) + } + err = indexer.InitIndex(ctx, esClient) + if err != nil { + return fmt.Errorf("initializing index: %w", err) + } + err = indexer.Seed(cmd.Context(), esClient) + if err != nil { + return fmt.Errorf("indexing seed data: %w", err) + } + return nil + }, +} diff --git a/feeds/lille.go b/feeds/lille.go new file mode 100644 index 0000000..b588dae --- /dev/null +++ b/feeds/lille.go @@ -0,0 +1,46 @@ +package feeds + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + + "github.com/elastic/go-elasticsearch/v9" + "github.com/elastic/go-elasticsearch/v9/typedapi/esdsl" + "tangled.org/dhawos.dev/bskyfeedctl/indexer" +) + +//go:embed lille.png +var lilleFeedAvatar []byte + +const LilleFeedID = "lille" + +func LilleFeed(es *elasticsearch.TypedClient) *Feed { + return &Feed{ + Avatar: lilleFeedAvatar, + ShortName: LilleFeedID, + DisplayName: "Lille", + Description: "Ce fil est en développement. Il est susceptible de changer. Un fil non officiel contenant les posts parlant de Lille et de la MEL.\n Je n'ai aucun lien avec la MEL ou la ville de Lille.", + SelectionFunc: func(ctx context.Context, req *FeedRequest) (FeedSkeletonResponse, error) { + res, err := es.Search(). + Index(indexer.IndexName). + Query(esdsl.NewMatchQuery(indexer.TextFieldName, "lille")). + Size(int(req.Limit)). + Do(ctx) + if err != nil { + return FeedSkeletonResponse{}, fmt.Errorf("requesting elastic for lille related posts: %w", err) + } + result := make([]FeedResponse, 0, len(res.Hits.Hits)) + for _, hit := range res.Hits.Hits { + var post indexer.PostDocument + err := json.Unmarshal(hit.Source_, &post) + if err != nil { + return FeedSkeletonResponse{}, fmt.Errorf("unmarshalling result from elasticsearch", err) + } + result = append(result, FeedResponse{Post: post.DID}) + } + return FeedSkeletonResponse{Cursor: "", Feed: result}, nil + }, + } +} diff --git a/feeds/lille.png b/feeds/lille.png new file mode 100644 index 0000000000000000000000000000000000000000..8fa011fff625a5b55a16d6d552217bfae1f57ec3 GIT binary patch literal 45100 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 D>Nq0= literal 0 HcmV?d00001 diff --git a/feeds/types.go b/feeds/types.go index 49e366a..138830b 100644 --- a/feeds/types.go +++ b/feeds/types.go @@ -2,14 +2,12 @@ package feeds import "context" -type BlueskyFeed interface { - GetFeedSkeleton(context.Context, FeedRequest) (FeedSkeletonResponse, error) - ShortName() string - DID() string - DisplayName() string - Description() string - PreviousRecord() *string - Avatar() []byte +type Feed struct { + Avatar []byte + ShortName string + DisplayName string + Description string + SelectionFunc func(ctx context.Context, req *FeedRequest) (FeedSkeletonResponse, error) } type FeedRequest struct { diff --git a/flake.nix b/flake.nix index 909692d..605618f 100644 --- a/flake.nix +++ b/flake.nix @@ -8,6 +8,7 @@ outputs = {nixpkgs, ...}: let systems = [ "x86_64-linux" + "aarch64-darwin" ]; in { devShells = builtins.listToAttrs ( diff --git a/indexer/elasticsearch.go b/indexer/elasticsearch.go index 1418132..4fe8b12 100644 --- a/indexer/elasticsearch.go +++ b/indexer/elasticsearch.go @@ -9,6 +9,8 @@ import ( "github.com/elastic/go-elasticsearch/v9/typedapi/types" ) +const TextFieldName = "text" + type ElasticsearchConfig struct { Host string Username string @@ -32,14 +34,14 @@ func NewElasticTypedClient(config ElasticsearchConfig) (*elasticsearch.TypedClie } func insertPost(ctx context.Context, client *elasticsearch.TypedClient, post *PostDocument) error { - _, err := client.Index(indexName). + _, err := client.Index(IndexName). Document(*post). Do(ctx) return err } func InitIndex(ctx context.Context, es *elasticsearch.TypedClient) error { - exists, err := es.Indices.Exists(indexName).Do(ctx) + exists, err := es.Indices.Exists(IndexName).Do(ctx) if err != nil { return fmt.Errorf("checking index existence: %w", err) } @@ -53,6 +55,6 @@ func InitIndex(ctx context.Context, es *elasticsearch.TypedClient) error { AddProperty("text", types.NewTextProperty()). AddProperty("likes", types.NewIntegerNumberProperty()). AddProperty("reposts", types.NewIntegerNumberProperty()) - _, err = es.Indices.Create(indexName).Mappings(properties).Settings(esdsl.NewIndexSettings().NumberOfReplicas("0")).Do(ctx) + _, err = es.Indices.Create(IndexName).Mappings(properties).Settings(esdsl.NewIndexSettings().NumberOfReplicas("0")).Do(ctx) return err } diff --git a/indexer/firehose.go b/indexer/firehose.go index 7c52d1a..c43363f 100644 --- a/indexer/firehose.go +++ b/indexer/firehose.go @@ -21,7 +21,7 @@ import ( "github.com/gorilla/websocket" ) -const indexName = "posts" +const IndexName = "posts" type FirehoseConfig struct { Host string diff --git a/indexer/seed.go b/indexer/seed.go new file mode 100644 index 0000000..acfb92b --- /dev/null +++ b/indexer/seed.go @@ -0,0 +1,37 @@ +package indexer + +import ( + "context" + _ "embed" + "encoding/json" + "fmt" + + "github.com/elastic/go-elasticsearch/v9" + "github.com/elastic/go-elasticsearch/v9/typedapi/types" +) + +//go:embed test_data.json +var testData []byte + +func Seed(ctx context.Context, es *elasticsearch.TypedClient) error { + var parsedData []PostDocument + err := json.Unmarshal(testData, &parsedData) + if err != nil { + return fmt.Errorf("could not parse test data: %w", err) + } + bulk := es.Bulk() + indexName := IndexName + for _, item := range parsedData { + if err := bulk.IndexOp(types.IndexOperation{Index_: &indexName}, item); err != nil { + return fmt.Errorf("adding item to bulk operation: %w", err) + } + } + res, err := bulk.Do(ctx) + if err != nil { + return fmt.Errorf("performing bulk operation: %w", err) + } + if res.Errors { + return fmt.Errorf("at least one operation failed") + } + return nil +} diff --git a/indexer/test_data.json b/indexer/test_data.json new file mode 100644 index 0000000..6fd54f9 --- /dev/null +++ b/indexer/test_data.json @@ -0,0 +1,102 @@ +[ + {"did": "did:plc:dev001", "text": "Je viens de passer 3 heures à chercher un bug qui venait d'un point-virgule manquant. La vie de dev est magnifique.", "likes": 245, "reposts": 34}, + {"did": "did:plc:dev002", "text": "Le passage à Go pour nos microservices a divisé notre consommation de RAM par dix. Impressionnant !", "likes": 89, "reposts": 12}, + {"did": "did:plc:dev003", "text": "Est-ce qu'on peut s'accorder sur le fait que centrer une div en CSS n'est plus une excuse valable pour pleurer en 2026 ?", "likes": 512, "reposts": 88}, + {"did": "did:plc:dev004", "text": "Le cache est vide, la base de données est clean, mais ça ne marche toujours pas. Il est temps de prendre un café.", "likes": 134, "reposts": 45}, + {"did": "did:plc:dev005", "text": "Docker m'a simplifié la vie, jusqu'au jour où j'ai dû comprendre comment fonctionnaient les networks internes.", "likes": 76, "reposts": 5}, + {"did": "did:plc:dev006", "text": "Rien de plus satisfaisant que de voir tous les tests unitaires passer au vert du premier coup.", "likes": 210, "reposts": 15}, + {"did": "did:plc:dev007", "text": "Je refuse de participer au débat Vim vs Emacs. VS Code avec les bons plugins, c'est la paix dans le monde.", "likes": 340, "reposts": 67}, + {"did": "did:plc:dev008", "text": "Mon historique Git est une tragédie en trois actes : 'wip', 'wip 2', et 'ça marche enfin pitié'.", "likes": 890, "reposts": 230}, + {"did": "did:plc:dev009", "text": "Le déploiement en production un vendredi soir ? Même pas peur. (Si, complètement terrifié).", "likes": 445, "reposts": 112}, + {"did": "did:plc:dev010", "text": "Kubernetes est formidable pour gérer la charge, mais la courbe d'apprentissage ressemble à un mur d'escalade sans prises.", "likes": 156, "reposts": 28}, + {"did": "did:plc:dev011", "text": "Je lis la documentation de cette API depuis ce matin, et j'en déduis que l'auteur déteste l'humanité.", "likes": 321, "reposts": 90}, + {"did": "did:plc:dev012", "text": "Un client m'a demandé de 'juste rajouter un bouton'. Si seulement il savait ce que ça implique en backend.", "likes": 278, "reposts": 54}, + {"did": "did:plc:dev013", "text": "TypeScript m'a sauvé de tellement d'erreurs bêtes que je ne peux plus m'en passer aujourd'hui.", "likes": 198, "reposts": 33}, + {"did": "did:plc:dev014", "text": "Pourquoi mon code marche-t-il sur ma machine locale mais explose complètement en staging ?", "likes": 402, "reposts": 105}, + {"did": "did:plc:dev015", "text": "La refactorisation de code legacy, c'est comme de l'archéologie : on ne sait jamais sur quoi on va tomber.", "likes": 167, "reposts": 19}, + {"did": "did:plc:dev016", "text": "Aujourd'hui, j'ai supprimé 500 lignes de code mort. Meilleure sensation de la semaine.", "likes": 289, "reposts": 41}, + {"did": "did:plc:dev017", "text": "Le syndrome de l'imposteur frappe toujours au moment où on te demande d'estimer la durée d'une tâche complexe.", "likes": 534, "reposts": 145}, + {"did": "did:plc:dev018", "text": "Je suis développeur fullstack, ce qui signifie que je suis moyen partout et expert nulle part.", "likes": 612, "reposts": 178}, + {"did": "did:plc:dev019", "text": "Le mode sombre n'est pas une option d'interface, c'est un mode de vie pour préserver mes yeux de développeur.", "likes": 345, "reposts": 72}, + {"did": "did:plc:dev020", "text": "Quelqu'un a décidé de changer l'architecture au milieu du sprint. Je vais pleurer en silence.", "likes": 123, "reposts": 24}, + {"did": "did:plc:dev021", "text": "L'Open Source est magnifique, sauf quand une dépendance critique est maintenue par une seule personne qui a disparu.", "likes": 476, "reposts": 99}, + {"did": "did:plc:dev022", "text": "Je passe plus de temps à nommer mes variables qu'à écrire la logique de mes fonctions.", "likes": 234, "reposts": 56}, + {"did": "did:plc:dev023", "text": "Le machine learning, c'est beaucoup de statistiques, énormément de données, et un peu de magie noire.", "likes": 189, "reposts": 37}, + {"did": "did:plc:dev024", "text": "Quand tu lances ta compilation en Rust et que tu as le temps d'aller préparer un repas complet avant la fin.", "likes": 312, "reposts": 65}, + {"did": "did:plc:dev025", "text": "La sécurité informatique devrait être pensée dès le premier jour, pas ajoutée comme un pansement à la fin.", "likes": 145, "reposts": 21}, + {"did": "did:plc:lil026", "text": "Rien de tel qu'une pinte en terrasse sur la Grand'Place de Lille quand le soleil pointe enfin le bout de son nez.", "likes": 412, "reposts": 34}, + {"did": "did:plc:lil027", "text": "Le marché de Wazemmes le dimanche matin, c'est vraiment l'âme de la ville. Les couleurs, les odeurs, l'ambiance !", "likes": 289, "reposts": 45}, + {"did": "did:plc:lil028", "text": "Un vrai Welsh au Maroilles, avec une bonne portion de frites. C'est lourd, c'est gras, mais c'est tellement bon.", "likes": 567, "reposts": 112}, + {"did": "did:plc:lil029", "text": "Se perdre volontairement dans les petites rues pavées du Vieux-Lille reste mon activité préférée du week-end.", "likes": 345, "reposts": 56}, + {"did": "did:plc:lil030", "text": "Le parc de la Citadelle en automne offre des couleurs incroyables, parfait pour une balade digestive.", "likes": 198, "reposts": 23}, + {"did": "did:plc:lil031", "text": "Je suis passé par la gare Saint-Sauveur hier soir, l'exposition temporaire est vraiment dingue cette année.", "likes": 156, "reposts": 18}, + {"did": "did:plc:lil032", "text": "La Braderie me manque. L'odeur des moules, les montagnes de coquilles, chiner des vieux vinyles dès l'aube...", "likes": 890, "reposts": 245}, + {"did": "did:plc:lil033", "text": "Traverser Euralille aux heures de pointe m'a rappelé pourquoi j'évite les centres commerciaux en semaine.", "likes": 112, "reposts": 14}, + {"did": "did:plc:lil034", "text": "Le beffroi de l'Hôtel de Ville éclairé dans la brume nocturne, ça donne des photos dignes d'un film noir.", "likes": 423, "reposts": 67}, + {"did": "did:plc:lil035", "text": "Impossible de passer par la rue de Béthune sans croiser quelqu'un qu'on connaît, c'est la loi de Lille.", "likes": 234, "reposts": 31}, + {"did": "did:plc:lil036", "text": "Un merveilleux de chez Fred pour le goûter, c'est le genre de petit plaisir qu'on ne refuse jamais.", "likes": 678, "reposts": 145}, + {"did": "did:plc:lil037", "text": "Le métro lillois en plein été, sans climatisation... une expérience tropicale que je ne recommande pas.", "likes": 345, "reposts": 89}, + {"did": "did:plc:lil038", "text": "J'ai découvert un petit estaminet planqué vers la rue de la Monnaie, l'accueil était chaleureux comme on les aime ici.", "likes": 187, "reposts": 22}, + {"did": "did:plc:lil039", "text": "Le stade Pierre Mauroy bouillonnait ce week-end pour le match du LOSC. Une ambiance vraiment spectaculaire !", "likes": 512, "reposts": 98}, + {"did": "did:plc:lil040", "text": "Trouver une place pour se garer vers République le samedi après-midi relève de la mission impossible.", "likes": 145, "reposts": 17}, + {"did": "did:plc:lil041", "text": "La vue depuis la terrasse panoramique d'Euratechnologies au coucher du soleil est très sous-estimée.", "likes": 267, "reposts": 39}, + {"did": "did:plc:lil042", "text": "Lire un livre tranquillement assis sur les marches de l'Opéra pendant que la ville s'agite autour, un vrai bonheur.", "likes": 198, "reposts": 24}, + {"did": "did:plc:lil043", "text": "La pluie lilloise a son propre caractère : elle ne mouille pas beaucoup mais elle dure trois jours d'affilée.", "likes": 456, "reposts": 77}, + {"did": "did:plc:lil044", "text": "Le Furet du Nord sur la Grand'Place, je peux y entrer pour cinq minutes et y rester bloqué deux heures sans m'en rendre compte.", "likes": 389, "reposts": 62}, + {"did": "did:plc:lil045", "text": "Faire du vélo sur les pavés du Vieux-Lille demande une suspension robuste ou des poignets en acier trempé.", "likes": 210, "reposts": 29}, + {"did": "did:plc:lil046", "text": "La friterie Meunier reste mon incontournable quand je veux manger sur le pouce près de la gare.", "likes": 167, "reposts": 15}, + {"did": "did:plc:lil047", "text": "La piscine musée de Roubaix, juste à côté, est un chef-d'œuvre absolu. J'y retournerais tous les mois si je pouvais.", "likes": 478, "reposts": 105}, + {"did": "did:plc:lil048", "text": "Les soirées étudiantes rue Masséna me semblent bien loin maintenant, mais le bruit porte toujours jusqu'à chez moi !", "likes": 123, "reposts": 11}, + {"did": "did:plc:lil049", "text": "J'ai testé la nouvelle micro-brasserie près de Cormontaigne, leur IPA est une petite merveille d'amertume.", "likes": 189, "reposts": 26}, + {"did": "did:plc:lil050", "text": "Rien de plus lillois que de terminer une soirée en mangeant une frite-fricadelle à 3h du matin.", "likes": 567, "reposts": 134}, + {"did": "did:plc:cyc051", "text": "Le vent de face sur 20 kilomètres ce matin m'a fait regretter de ne pas avoir pris l'option vélo électrique.", "likes": 345, "reposts": 45}, + {"did": "did:plc:cyc052", "text": "Sortie gravel incroyable ce week-end dans la forêt. J'ai de la boue jusqu'aux oreilles mais je suis heureux.", "likes": 210, "reposts": 28}, + {"did": "did:plc:cyc053", "text": "Crever un pneu sous la pluie à 5 km de la maison, c'est exactement pour ça que j'ai toujours trois chambres à air sur moi.", "likes": 178, "reposts": 19}, + {"did": "did:plc:cyc054", "text": "Le vélotaf, c'est l'assurance d'arriver au bureau réveillé, de bonne humeur, et avec un bon petit cardio fait pour la journée.", "likes": 678, "reposts": 156}, + {"did": "did:plc:cyc055", "text": "J'ai enfin réussi à battre mon record personnel sur ce segment Strava en côte. Mes poumons sont en feu.", "likes": 289, "reposts": 37}, + {"did": "did:plc:cyc056", "text": "Les pistes cyclables qui s'arrêtent brusquement au milieu de nulle part sont la pire aberration urbaine.", "likes": 890, "reposts": 245}, + {"did": "did:plc:cyc057", "text": "Entendre le doux ronronnement d'une chaîne parfaitement nettoyée et lubrifiée est ASMR pour tout cycliste qui se respecte.", "likes": 412, "reposts": 67}, + {"did": "did:plc:cyc058", "text": "Les automobilistes qui te frôlent à 80 km/h sur une route de campagne... On partage la route, s'il vous plaît !", "likes": 756, "reposts": 198}, + {"did": "did:plc:cyc059", "text": "Rien de mieux que le premier coup de pédale un dimanche matin très tôt, quand la ville dort encore.", "likes": 345, "reposts": 52}, + {"did": "did:plc:cyc060", "text": "Investir dans un bon cuissard est la meilleure décision que j'ai pu prendre. Mon séant me remercie à chaque longue sortie.", "likes": 234, "reposts": 31}, + {"did": "did:plc:cyc061", "text": "Le bikepacking m'a appris le minimalisme. Chaque gramme compte quand on doit monter un col alpin.", "likes": 198, "reposts": 24}, + {"did": "did:plc:cyc062", "text": "Je disais que le vélo aérodynamique ne changerait rien, jusqu'à ce que je l'essaye sur le plat. C'est une fusée.", "likes": 156, "reposts": 17}, + {"did": "did:plc:cyc063", "text": "La règle non écrite du cyclisme : on se salue toujours quand on se croise, peu importe le vélo.", "likes": 567, "reposts": 89}, + {"did": "did:plc:cyc064", "text": "Faire l'ascension du Mont Ventoux était mon objectif de l'année. La vue au sommet efface toute la douleur.", "likes": 423, "reposts": 76}, + {"did": "did:plc:cyc065", "text": "Rouler de nuit avec un bon éclairage apporte une ambiance totalement différente, presque méditative.", "likes": 187, "reposts": 22}, + {"did": "did:plc:cyc066", "text": "Le nettoyage du vélo après une sortie hivernale sur des routes pleines de sel prend plus de temps que la sortie elle-même.", "likes": 210, "reposts": 34}, + {"did": "did:plc:cyc067", "text": "Un rayon cassé en plein milieu d'une descente, c'est la grosse frayeur garantie. Heureusement, plus de peur que de mal.", "likes": 134, "reposts": 12}, + {"did": "did:plc:cyc068", "text": "Voir des enfants apprendre à faire du vélo sans roulettes pour la première fois me donne toujours le sourire.", "likes": 456, "reposts": 65}, + {"did": "did:plc:cyc069", "text": "Le choix des pneus est un débat sans fin. Tubeless ou chambre à air ? La guerre est éternelle sur les forums.", "likes": 278, "reposts": 41}, + {"did": "did:plc:cyc070", "text": "S'arrêter à la boulangerie à mi-parcours pour manger un flan, c'est la base de l'entraînement d'endurance.", "likes": 612, "reposts": 105}, + {"did": "did:plc:cyc071", "text": "La sensation de glisse sur un bel asphalte tout neuf, sans aucune imperfection, c'est la perfection absolue.", "likes": 389, "reposts": 54}, + {"did": "did:plc:cyc072", "text": "Les sas vélos aux feux rouges sont utiles uniquement si les voitures ne viennent pas s'y garer systématiquement.", "likes": 534, "reposts": 123}, + {"did": "did:plc:cyc073", "text": "Partir pour 30km et finir par en faire 80 parce qu'on s'est perdu et que le paysage était trop beau.", "likes": 467, "reposts": 88}, + {"did": "did:plc:cyc074", "text": "Les passages à niveau par temps humide : la hantise de tout cycliste qui tient à sa clavicule.", "likes": 321, "reposts": 47}, + {"did": "did:plc:cyc075", "text": "Réparer soi-même son dérailleur avec un tutoriel sur internet, c'est 20% de technique et 80% de patience.", "likes": 245, "reposts": 33}, + {"did": "did:plc:cin076", "text": "Le dernier film de Denis Villeneuve prouve encore une fois qu'il est l'un des plus grands réalisateurs de notre époque.", "likes": 789, "reposts": 156}, + {"did": "did:plc:cin077", "text": "Regarder un film en VF quand il a été pensé pour sa langue d'origine, c'est perdre une grande partie du jeu d'acteur.", "likes": 456, "reposts": 89}, + {"did": "did:plc:cin078", "text": "Le cinéma d'horreur indépendant propose des œuvres tellement plus intelligentes que les gros blockbusters remplis de jumpscares.", "likes": 345, "reposts": 67}, + {"did": "did:plc:cin079", "text": "Entendre les gens manger leurs pop-corn la bouche ouverte pendant une scène dramatique silencieuse... L'enfer sur terre.", "likes": 890, "reposts": 234}, + {"did": "did:plc:cin080", "text": "La photographie de Roger Deakins me laisse sans voix à chaque fois. Chaque plan de ses films est un tableau.", "likes": 512, "reposts": 98}, + {"did": "did:plc:cin081", "text": "Un bon scénario peut sauver une mauvaise réalisation, mais l'inverse est très rarement vrai.", "likes": 423, "reposts": 76}, + {"did": "did:plc:cin082", "text": "Je me suis refait l'intégrale du Seigneur des Anneaux en version longue ce week-end. Plus de 11 heures de pur bonheur.", "likes": 956, "reposts": 312}, + {"did": "did:plc:cin083", "text": "Les effets spéciaux pratiques (maquillage, animatroniques) vieilliront toujours mieux que des CGI bâclés.", "likes": 678, "reposts": 145}, + {"did": "did:plc:cin084", "text": "Le cinéma coréen ne cesse de me surprendre par sa capacité à mélanger les genres au sein d'un même film.", "likes": 389, "reposts": 65}, + {"did": "did:plc:cin085", "text": "La musique de Hans Zimmer, c'est littéralement 50% de l'impact émotionnel des films d'Interstellar à Inception.", "likes": 734, "reposts": 167}, + {"did": "did:plc:cin086", "text": "Aller seul au cinéma un mardi après-midi, c'est un luxe incroyable qu'il faut savourer au moins une fois.", "likes": 412, "reposts": 88}, + {"did": "did:plc:cin087", "text": "Les fins ouvertes qui te laissent réfléchir pendant des jours après le générique sont de loin mes préférées.", "likes": 289, "reposts": 45}, + {"did": "did:plc:cin088", "text": "Les remakes en prises de vues réelles des vieux classiques d'animation perdent presque toujours leur âme originelle.", "likes": 567, "reposts": 123}, + {"did": "did:plc:cin089", "text": "Le générique de fin devrait être obligatoire à regarder, par respect pour les milliers de personnes qui ont fait le film.", "likes": 234, "reposts": 41}, + {"did": "did:plc:cin090", "text": "Tarantino reste le maître absolu des dialogues qui ne servent à rien pour l'intrigue mais qui définissent parfaitement un personnage.", "likes": 645, "reposts": 112}, + {"did": "did:plc:cin091", "text": "Je préfère voir un réalisateur prendre des risques et rater son film que regarder une production aseptisée sans saveur.", "likes": 378, "reposts": 72}, + {"did": "did:plc:cin092", "text": "Les bandes-annonces qui dévoilent littéralement tous les rebondissements de l'intrigue devraient être illégales.", "likes": 823, "reposts": 210}, + {"did": "did:plc:cin093", "text": "Découvrir une pépite méconnue dans les tréfonds d'une plateforme de streaming est un sentiment merveilleux.", "likes": 198, "reposts": 28}, + {"did": "did:plc:cin094", "text": "La salle de l'UGC Ciné Cité était pleine à craquer hier soir, ça fait plaisir de voir que les gens vont toujours au cinéma.", "likes": 267, "reposts": 35}, + {"did": "did:plc:cin095", "text": "Certains acteurs ont un tel charisme qu'ils arrivent à te garder scotché à l'écran même s'ils ne font que boire un café.", "likes": 312, "reposts": 51}, + {"did": "did:plc:cin096", "text": "Le rythme d'un film est primordial. Un film de 3 heures peut passer en un éclair, et un film d'une heure trente peut sembler interminable.", "likes": 489, "reposts": 84}, + {"did": "did:plc:cin097", "text": "Je collectionne les affiches de films depuis dix ans, mes murs n'ont plus un seul centimètre carré de libre.", "likes": 145, "reposts": 17}, + {"did": "did:plc:cin098", "text": "L'ambiance lors des avant-premières avec la présence de l'équipe du film apporte vraiment un plus à l'expérience.", "likes": 223, "reposts": 29}, + {"did": "did:plc:cin099", "text": "Revoir un film d'enfance avec des yeux d'adulte et découvrir un sous-texte qu'on n'avait jamais compris, c'est fascinant.", "likes": 534, "reposts": 92}, + {"did": "did:plc:cin100", "text": "Le son Dolby Atmos dans une bonne salle obscure, c'est quand même la garantie d'une immersion totale.", "likes": 367, "reposts": 61} +] diff --git a/server/controller.go b/server/controller.go index 2c05c44..e7000de 100644 --- a/server/controller.go +++ b/server/controller.go @@ -4,15 +4,23 @@ import ( "encoding/json" "fmt" "log/slog" + "math" "net/http" + "strconv" "tangled.org/dhawos.dev/bskyfeedctl/feeds" + "tangled.org/dhawos.dev/bskyfeedctl/indexer" "github.com/bluesky-social/indigo/atproto/syntax" ) var statusHealthOK = healthResponse{Status: "ok"} +const ( + generatorCollection = "app.bsky.feed.generator" + defaultFeedLimit = "30" +) + type healthResponse struct { Status string `json:"status"` } @@ -31,17 +39,24 @@ type BlueskyFeedGeneratorConfig struct { } type BlueskyFeedGenerator struct { - serverConfig ServerConfig - feedConfig FeedConfig - feeds map[string]feeds.BlueskyFeed + serverConfig ServerConfig + feedConfig FeedConfig + elasticConfig indexer.ElasticsearchConfig + feeds map[string]feeds.Feed } -func NewBlueskyFeedGenerator(serverConfig ServerConfig, feedConfig FeedConfig) BlueskyFeedGenerator { - return BlueskyFeedGenerator{ +func NewBlueskyFeedGenerator(serverConfig ServerConfig, feedConfig FeedConfig, esConfig indexer.ElasticsearchConfig) (*BlueskyFeedGenerator, error) { + elasticClient, err := indexer.NewElasticTypedClient(esConfig) + if err != nil { + return nil, fmt.Errorf("initialiazing elastic client: %w", err) + } + return &BlueskyFeedGenerator{ serverConfig: serverConfig, feedConfig: feedConfig, - feeds: map[string]feeds.BlueskyFeed{}, - } + feeds: map[string]feeds.Feed{ + feeds.LilleFeedID: *feeds.LilleFeed(elasticClient), + }, + }, nil } func (bfg *BlueskyFeedGenerator) Start() error { @@ -70,11 +85,37 @@ func (bfg *BlueskyFeedGenerator) Start() error { } func (bfg *BlueskyFeedGenerator) appBskyFeedGetFeedSkeletonHandler(w http.ResponseWriter, req *http.Request) { + // Parse request feed := req.URL.Query().Get("feed") if feed == "" { http.Error(w, "", http.StatusBadRequest) return } + cursor := req.URL.Query().Get("cursor") + if cursor == "" { + cursor = "-1" + } + cursorInt64, err := strconv.ParseInt(cursor, 10, 64) + if cursorInt64 < 0 { + cursorInt64 = math.MaxInt64 + } + if err != nil { + slog.Error("could not parse cursor", "cursor", cursor, "err", err) + http.Error(w, "", http.StatusBadRequest) + return + } + limit := req.URL.Query().Get("limit") + if limit == "" { + limit = defaultFeedLimit + } + limitInt, err := strconv.ParseInt(limit, 10, 32) + limitInt32 := int32(limitInt) + if err != nil { + slog.Error("could not parse limit", "limit", limit, "err", err) + http.Error(w, "", http.StatusBadRequest) + return + } + atURI, err := syntax.ParseATURI(feed) if err != nil { http.Error(w, "", http.StatusBadRequest) @@ -85,10 +126,32 @@ func (bfg *BlueskyFeedGenerator) appBskyFeedGetFeedSkeletonHandler(w http.Respon http.Error(w, "", http.StatusBadRequest) return } - _, err = fmt.Fprint(w, "coucou") + if atURI.Collection().String() != generatorCollection { + slog.Error("invalid collection", "collection", atURI.Collection().String(), "expectedCollection", generatorCollection) + http.Error(w, "", http.StatusBadRequest) + return + } + algo, exists := bfg.feeds[atURI.RecordKey().String()] + if !exists { + slog.Error("feed not found", "feed", atURI.RecordKey()) + http.Error(w, "", http.StatusNotFound) + return + } + feedRequest := feeds.FeedRequest{ + Lang: "fr", + Cursor: cursorInt64, + Limit: limitInt32, + } + response, err := algo.SelectionFunc(req.Context(), &feedRequest) + if err != nil { + slog.Error("unable to generate feed", slog.String("feed", atURI.RecordKey().String()), slog.Any("err", err)) + http.Error(w, "unable to generate feed", http.StatusInternalServerError) + return + } + err = json.NewEncoder(w).Encode(response) if err != nil { - slog.Error("error generating response", slog.Any("err", err)) - http.Error(w, "", http.StatusInternalServerError) + slog.Error("unable to encode response", slog.String("feed", atURI.RecordKey().String()), slog.Any("err", err)) + http.Error(w, "unable to encode response", http.StatusInternalServerError) return } } -- 2.51.2