Skip to content

Commit

Permalink
Initial Commit
Browse files Browse the repository at this point in the history
  • Loading branch information
redsolver committed Nov 28, 2023
0 parents commit 0e2472f
Show file tree
Hide file tree
Showing 20 changed files with 3,313 additions and 0 deletions.
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# https://dart.dev/guides/libraries/private-files
# Created by `dart pub`
.dart_tool/
661 changes: 661 additions & 0 deletions LICENSE

Large diffs are not rendered by default.

30 changes: 30 additions & 0 deletions analysis_options.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# This file configures the static analysis results for your project (errors,
# warnings, and lints).
#
# This enables the 'recommended' set of lints from `package:lints`.
# This set helps identify many issues that may lead to problems when running
# or consuming Dart code, and enforces writing Dart using a single, idiomatic
# style and format.
#
# If you want a smaller set of lints you can change this to specify
# 'package:lints/core.yaml'. These are just the most critical lints
# (the recommended set includes the core lints).
# The core lints are also what is used by pub.dev for scoring packages.

include: package:lints/recommended.yaml

# Uncomment the following section to specify additional rules.

# linter:
# rules:
# - camel_case_types

# analyzer:
# exclude:
# - path/to/excluded/files/**

# For more information about the core and recommended set of lints, see
# https://dart.dev/go/core-lints

# For additional information about configuring this file, see
# https://dart.dev/guides/language/analysis-options
29 changes: 29 additions & 0 deletions bin/atproto_bridge_proxy.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import 'package:logger/logger.dart';
import 'package:atproto_bridge_proxy/server.dart';

final logger = Logger(
printer: SimplePrinter(),
// printer: LogfmtPrinter(),
// printer: PrettyPrinter(),
// output: ConsoleOutput(),
filter: PrintEverythingFilter(),
);

class PrintEverythingFilter extends LogFilter {
@override
bool shouldLog(LogEvent event) {
return true;
}
}

void main(List<String> arguments) async {
if (arguments.isEmpty) {
throw 'Usage: bridge_proxy PUBLIC_PROXY_URL';
}
final server = BridgeProxyServer(
service: arguments.length > 1 ? arguments[1] : 'bsky.social',
logger: logger,
serviceEndpoint: arguments[0],
);
await server.start();
}
14 changes: 14 additions & 0 deletions config/example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"bridge": {
"rss": {
"following": [
"https://blueskyweb.xyz/rss.xml"
]
},
"youtube": {
"following": [
"UC5EcK7QV6Z9M70nXoaiJ9BA"
]
}
}
}
30 changes: 30 additions & 0 deletions lib/bridge/base.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import 'package:atproto_bridge_proxy/server.dart';
import 'package:bluesky/bluesky.dart';

abstract class Bridge {
final BridgeProxyServer server;
Bridge({required this.server});

Future<void> init() async {}
Future<Feed> getFeed({
required AtUri generatorUri,
int? limit,
String? cursor,
});

Future<PostThread> getPostThread({
required AtUri uri,
int? depth,
int? parentHeight,
});

Future<Feed> getAuthorFeed({
required String actor,
int? limit,
String? cursor,
});

Future<ActorProfile> getProfile({
required String actor,
});
}
247 changes: 247 additions & 0 deletions lib/bridge/hacker_news.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import 'dart:convert';

import 'package:atproto_bridge_proxy/bridge/base.dart';
import 'package:atproto_bridge_proxy/util/clean_handle.dart';
import 'package:atproto_bridge_proxy/util/make_cid.dart';
import 'package:atproto_bridge_proxy/util/preprocess_html.dart';
import 'package:atproto_bridge_proxy/util/string.dart';
import 'package:bluesky/bluesky.dart';
import 'package:html_unescape/html_unescape.dart';
import 'package:http/http.dart' as http;

class HackerNewsBridge extends Bridge {
final httpClient = http.Client();
final htmlUnescape = HtmlUnescape();

HackerNewsBridge({
required super.server,
});

Future<dynamic> fetchAPI(String path) async {
final res = await httpClient.get(
Uri.parse(
'https://hacker-news.firebaseio.com$path',
),
headers: {
'accept': 'application/json',
},
);
if (res.statusCode != 200) throw 'HTTP ${res.statusCode}: ${res.body}';
return jsonDecode(res.body);
}

final itemCache = <int, Post>{};

final kidsMap = <int, List<int>>{};
final parentMap = <int, int>{};

Future<Post> loadItem(int id, {required bool useCache}) async {
if (useCache) {
if (itemCache.containsKey(id)) return itemCache[id]!;
}
final res = await fetchAPI('/v0/item/$id.json');

final dt = DateTime.fromMillisecondsSinceEpoch((res['time'] ?? 0) * 1000);
final String by = res['by'] ?? '';
final did = 'did:bridge:hn:${encodeString(by)}';

if (res['kids'] != null) {
kidsMap[id] = res['kids'].cast<int>();
}
if (res['parent'] != null) {
parentMap[id] = res['parent'];
}

final uri = Uri.tryParse(res['url'] ?? '');

final thumbnail = uri?.host;

final postUri = AtUri.parse(
'at://$did/app.bsky.feed.post/$id',
);

final post = Post(
record: PostRecord(
// TODO Remove
// text: 'text',
text: res['text'] == null
? ''
// TODO Replace HTML links with facets :)
: res['title'] == null
? preprocessHtml(res['text'])
: '# ${res['title']}\n\n${preprocessHtml(res['text'])}',
createdAt: dt,
),
embed: res['url'] == null
? null
: EmbedView.external(
data: EmbedViewExternal(
type: 'app.bsky.embed.external#view',
external: EmbedViewExternalView(
uri: res['url'],
title: res['title'],
description: '',
thumbnail:
// TODO Maybe use different favicon preview API
'https://anxious-amber-tarsier.faviconkit.com/$thumbnail/256',
),
),
),
author: Actor(
displayName: by,
did: did,
handle: '${cleanHandle(by)}.news.ycombinator.com',
viewer: ActorViewer(
isMuted: false,
isBlockedBy: false,
),
),
uri: postUri,
cid: makeCID(postUri.toString()),
replyCount: res['descendants'] ?? res['kids']?.length ?? 0,
// repostCount: 100,
// likeCount: 100,
repostCount: -1,
likeCount: res['score'] ?? -1,
viewer: PostViewer(),
indexedAt: dt,
);
itemCache[id] = post;
return post;
}

@override
Future<Feed> getFeed(
{required AtUri generatorUri, int? limit, String? cursor}) async {
int offset = int.tryParse(cursor ?? '') ?? 0;
limit ??= 8;
if (limit > 16) {
limit = 16;
}

final res = await fetchAPI('/v0/${generatorUri.rkey}.json');

final posts = await Future.wait([
for (final id in res.sublist(offset, offset + limit))
loadItem(id, useCache: true),
]);
return Feed(
feed: [
for (final post in posts)
FeedView(
post: post,
),
],
cursor: (offset + limit).toString(),
);
}

Future<PostThreadView?> loadParent(int id) async {
final parentId = parentMap[id];
if (parentId == null) return null;
final post = await loadItem(parentId, useCache: true);
return PostThreadView.record(
data: PostThreadViewRecord(
type: 'app.bsky.feed.defs#threadViewPost',
post: post,
parent: await loadParent(
parentId,
),
replies: [],
),
);
}

@override
Future<PostThread> getPostThread(
{required AtUri uri, int? depth, int? parentHeight}) async {
final id = int.parse(uri.rkey);

final post = await loadItem(id, useCache: false);
final replies = await Future.wait([
for (final child in kidsMap[id] ?? []) loadReply(child, 1),
]);

return PostThread(
thread: PostThreadView.record(
data: PostThreadViewRecord(
type: 'app.bsky.feed.defs#threadViewPost',
post: post,
parent: await loadParent(id),
replies: replies,
),
));
}

Future<PostThreadView> loadReply(int id, int level) async {
final post = await loadItem(id, useCache: true);

final replies = level > 3
? <PostThreadView>[]
: await Future.wait([
for (final child in kidsMap[id] ?? []) loadReply(child, level + 1),
]);
return PostThreadView.record(
data: PostThreadViewRecord(
type: 'app.bsky.feed.defs#threadViewPost',
post: post,
parent: null,
replies: replies,
));
}

@override
Future<Feed> getAuthorFeed(
{required String actor, int? limit, String? cursor}) async {
final res = await fetchProfile(actor);
int offset = int.tryParse(cursor ?? '') ?? 0;

limit ??= 16;
if (limit > 16) {
limit = 16;
}

final posts = await Future.wait([
for (final id in res.$2.sublist(offset, offset + limit))
loadItem(id, useCache: true),
]);

return Feed(
feed: [
for (final post in posts)
FeedView(
post: post,
),
],
cursor: (offset + limit).toString(),
);
}

@override
Future<ActorProfile> getProfile({required String actor}) async {
return (await fetchProfile(actor)).$1;
}

Future<(ActorProfile, List<int>)> fetchProfile(String actor) async {
final username = decodeString(actor.split(':')[2]);
final profile = await fetchAPI(
'/v0/user/$username.json',
);
final List<int> submitted = (profile['submitted'] ?? []).cast<int>();
return (
ActorProfile(
did: actor,
handle: '${cleanHandle(actor)}.news.ycombinator.com',
followsCount: -1,
followersCount: -1,
displayName: profile['id'],
// indexedAt: DateTime.fromMillisecondsSinceEpoch(profile['time'] * 1000),
description:
'${preprocessHtml(profile['about'] ?? '')}\n\nKarma: ${profile['karma']}',
postsCount: submitted.length,
viewer: ActorViewer(isMuted: false, isBlockedBy: false),
),
submitted
);
}
}
Loading

0 comments on commit 0e2472f

Please sign in to comment.