trying to set up a matrix bridge
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
plugins {
|
||||
`maven-publish`
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":api"))
|
||||
compileOnly("com.velocitypowered:velocity-api:3.2.0-SNAPSHOT")
|
||||
|
||||
api("com.fasterxml.jackson.core:jackson-databind:2.17.2")
|
||||
api("com.fasterxml.jackson.core:jackson-core:2.17.2")
|
||||
api("com.fasterxml.jackson.core:jackson-annotations:2.17.2")
|
||||
}
|
||||
|
||||
tasks.withType<JavaCompile>().configureEach {
|
||||
options.encoding = "UTF-8"
|
||||
options.release.set(21)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package com.alttd.matrix;
|
||||
|
||||
public record MatrixConfig(
|
||||
String homeserver, // e.g. "http://127.0.0.1:8008"
|
||||
String asToken, // from as registration yaml
|
||||
String hsToken, // from as registration yaml
|
||||
String domain, // "matrix.alttd.com"
|
||||
String userPrefix, // "mc_"
|
||||
String bindHost, // "127.0.0.1"
|
||||
int bindPort // 9000
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package com.alttd.matrix;
|
||||
|
||||
import com.alttd.matrix.app_service.MatrixAppServiceHttpServer;
|
||||
import com.alttd.matrix.client.HttpMatrixClient;
|
||||
import com.alttd.matrix.interfaces.MatrixAppServiceServer;
|
||||
import com.alttd.matrix.interfaces.MatrixClient;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.InetSocketAddress;
|
||||
|
||||
public final class MatrixRuntime {
|
||||
|
||||
private MatrixRuntime() {
|
||||
}
|
||||
|
||||
public static MatrixAppServiceServer startAppService(MatrixConfig cfg) throws IOException {
|
||||
var server = new MatrixAppServiceHttpServer(
|
||||
new InetSocketAddress(cfg.bindHost(), cfg.bindPort()),
|
||||
cfg.hsToken()
|
||||
);
|
||||
server.start();
|
||||
return server;
|
||||
}
|
||||
|
||||
public static MatrixClient createClient(MatrixConfig cfg) {
|
||||
return new HttpMatrixClient(cfg.homeserver(), cfg.asToken());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
package com.alttd.matrix.app_service;
|
||||
|
||||
import com.alttd.matrix.interfaces.MatrixAppServiceServer;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Iterator;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public final class MatrixAppServiceHttpServer implements MatrixAppServiceServer {
|
||||
|
||||
private final HttpServer server;
|
||||
private final String hsToken;
|
||||
private final ObjectMapper om = new ObjectMapper();
|
||||
|
||||
private volatile Consumer<TransactionEvent> txListener;
|
||||
|
||||
public MatrixAppServiceHttpServer(InetSocketAddress bind, String hsToken) throws IOException {
|
||||
this.server = HttpServer.create(bind, 0);
|
||||
this.hsToken = hsToken;
|
||||
|
||||
server.createContext("/_matrix/app/v1/transactions", this::handleTransactions);
|
||||
server.createContext("/_matrix/app/v1/users", this::handleUsers);
|
||||
|
||||
server.setExecutor(Executors.newCachedThreadPool(r -> {
|
||||
Thread t = new Thread(r, "matrix-as-http");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
}));
|
||||
}
|
||||
|
||||
public void start() {
|
||||
server.start();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTransactionListener(Consumer<TransactionEvent> listener) {
|
||||
this.txListener = listener;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
server.stop(0);
|
||||
}
|
||||
|
||||
private void handleUsers(HttpExchange ex) throws IOException {
|
||||
if (!"GET".equals(ex.getRequestMethod())) {
|
||||
send(ex, 405, "{}");
|
||||
return;
|
||||
}
|
||||
if (!validHsToken(ex)) {
|
||||
send(ex, 401, "{}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Synapse asks "does this user exist?" -> for AS, answer 200 so Synapse will proceed.
|
||||
// You can optionally restrict to your namespace by inspecting the path.
|
||||
send(ex, 200, "{}");
|
||||
}
|
||||
|
||||
private void handleTransactions(HttpExchange ex) throws IOException {
|
||||
if (!"PUT".equals(ex.getRequestMethod())) {
|
||||
send(ex, 405, "{}");
|
||||
return;
|
||||
}
|
||||
if (!validHsToken(ex)) {
|
||||
send(ex, 401, "{}");
|
||||
return;
|
||||
}
|
||||
|
||||
JsonNode root;
|
||||
try (InputStream in = ex.getRequestBody()) {
|
||||
root = om.readTree(in);
|
||||
} catch (Exception e) {
|
||||
send(ex, 400, "{}");
|
||||
return;
|
||||
}
|
||||
|
||||
Consumer<TransactionEvent> listener = txListener;
|
||||
if (listener != null) {
|
||||
JsonNode events = root.path("events");
|
||||
if (events.isArray()) {
|
||||
for (Iterator<JsonNode> it = events.elements(); it.hasNext(); ) {
|
||||
JsonNode ev = it.next();
|
||||
|
||||
// only handle m.room.message text
|
||||
if (!"m.room.message".equals(ev.path("type").asText())) {
|
||||
continue;
|
||||
}
|
||||
if (!"m.text".equals(ev.path("content").path("msgtype").asText())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
String roomId = ev.path("room_id").asText(null);
|
||||
String sender = ev.path("sender").asText(null);
|
||||
String eventId = ev.path("event_id").asText(null);
|
||||
String body = ev.path("content").path("body").asText(null);
|
||||
|
||||
if (roomId != null && body != null) {
|
||||
listener.accept(new TransactionEvent(roomId, sender, body, eventId));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
send(ex, 200, "{}");
|
||||
}
|
||||
|
||||
private boolean validHsToken(HttpExchange ex) {
|
||||
String q = ex.getRequestURI().getRawQuery();
|
||||
if (q == null) {
|
||||
return false;
|
||||
}
|
||||
Map<String, String> params = parseQuery(q);
|
||||
String token = params.get("access_token");
|
||||
return token != null && token.equals(hsToken);
|
||||
}
|
||||
|
||||
private static Map<String, String> parseQuery(String raw) {
|
||||
var map = new java.util.HashMap<String, String>();
|
||||
for (String part : raw.split("&")) {
|
||||
int i = part.indexOf('=');
|
||||
if (i <= 0) {
|
||||
continue;
|
||||
}
|
||||
String k = urlDecode(part.substring(0, i));
|
||||
String v = urlDecode(part.substring(i + 1));
|
||||
map.put(k, v);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
private static String urlDecode(String s) {
|
||||
return URLDecoder.decode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void send(HttpExchange ex, int code, String body) throws IOException {
|
||||
byte[] b = body.getBytes(StandardCharsets.UTF_8);
|
||||
ex.getResponseHeaders().set("Content-Type", "application/json");
|
||||
ex.sendResponseHeaders(code, b.length);
|
||||
ex.getResponseBody().write(b);
|
||||
ex.close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.alttd.matrix.bridge;
|
||||
|
||||
import com.alttd.matrix.MatrixConfig;
|
||||
import com.alttd.matrix.interfaces.MatrixAppServiceServer;
|
||||
import com.alttd.matrix.interfaces.MatrixBridge;
|
||||
import com.alttd.matrix.interfaces.MatrixClient;
|
||||
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executor;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public final class DefaultMatrixBridge implements MatrixBridge {
|
||||
|
||||
private final MatrixClient client;
|
||||
private final MatrixConfig cfg;
|
||||
private final Executor io = Executors.newSingleThreadExecutor(r -> {
|
||||
Thread t = new Thread(r, "matrix-io");
|
||||
t.setDaemon(true);
|
||||
return t;
|
||||
});
|
||||
|
||||
private volatile InboundHandler inbound;
|
||||
private volatile RoomResolver rooms;
|
||||
|
||||
public DefaultMatrixBridge(MatrixConfig cfg, MatrixAppServiceServer asServer, MatrixClient client) {
|
||||
this.cfg = cfg;
|
||||
this.client = client;
|
||||
|
||||
asServer.setTransactionListener(evt -> {
|
||||
InboundHandler h = inbound;
|
||||
RoomResolver rr = rooms;
|
||||
if (h == null || rr == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// drop our own puppets to prevent loops
|
||||
if (evt.senderMxid() != null && evt.senderMxid().startsWith("@" + cfg.userPrefix())) {
|
||||
return;
|
||||
}
|
||||
|
||||
h.onMatrixChat(evt.roomId(), evt.senderMxid(), evt.body());
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setInboundHandler(InboundHandler handler) {
|
||||
this.inbound = handler;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRoomResolver(RoomResolver resolver) {
|
||||
this.rooms = resolver;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendChat(UUID playerUuid, String mcUsername, String serverName, String message) {
|
||||
RoomResolver rr = rooms;
|
||||
if (rr == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String roomId = rr.roomIdForServer(serverName);
|
||||
if (roomId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
String mxid = "@" + cfg.userPrefix() + playerUuid + ":" + cfg.domain();
|
||||
|
||||
io.execute(() -> {
|
||||
client.ensureUser(mxid);
|
||||
client.setDisplayName(mxid, mcUsername);
|
||||
client.ensureJoined(mxid, roomId);
|
||||
client.sendText(mxid, roomId, message);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package com.alttd.matrix.client;
|
||||
|
||||
import com.alttd.matrix.interfaces.MatrixClient;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
|
||||
import java.net.URI;
|
||||
import java.net.URLEncoder;
|
||||
import java.net.http.HttpClient;
|
||||
import java.net.http.HttpRequest;
|
||||
import java.net.http.HttpResponse;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.UUID;
|
||||
|
||||
public final class HttpMatrixClient implements MatrixClient {
|
||||
|
||||
private final HttpClient http = HttpClient.newBuilder()
|
||||
.connectTimeout(Duration.ofSeconds(5))
|
||||
.build();
|
||||
|
||||
private final ObjectMapper om = new ObjectMapper();
|
||||
|
||||
private final String hs; // e.g. http://127.0.0.1:8008
|
||||
private final String asToken; // AS access token
|
||||
|
||||
public HttpMatrixClient(String homeserver, String asToken) {
|
||||
this.hs = homeserver.endsWith("/") ? homeserver.substring(0, homeserver.length() - 1) : homeserver;
|
||||
this.asToken = asToken;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ensureUser(String mxid) {
|
||||
String localpart = localpart(mxid);
|
||||
|
||||
String url = hs + "/_matrix/client/v3/register"
|
||||
+ "?access_token=" + enc(asToken);
|
||||
|
||||
String body = "{\"type\":\"m.login.application_service\",\"username\":\"" + json(localpart) + "\"}";
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
sendIgnoreConflict(req);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setDisplayName(String mxid, String displayName) {
|
||||
String url = hs + "/_matrix/client/v3/profile/" + encPath(mxid) + "/displayname"
|
||||
+ "?access_token=" + enc(asToken)
|
||||
+ "&user_id=" + enc(mxid);
|
||||
|
||||
String body = "{\"displayname\":\"" + json(displayName) + "\"}";
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Content-Type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
sendOkOrIgnore(req);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void ensureJoined(String mxid, String roomId) {
|
||||
String url = hs + "/_matrix/client/v3/rooms/" + encPath(roomId) + "/join"
|
||||
+ "?access_token=" + enc(asToken)
|
||||
+ "&user_id=" + enc(mxid);
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Content-Type", "application/json")
|
||||
.POST(HttpRequest.BodyPublishers.ofString("{}"))
|
||||
.build();
|
||||
|
||||
sendOkOrIgnore(req);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void sendText(String mxid, String roomId, String bodyText) {
|
||||
String txnId = UUID.randomUUID().toString();
|
||||
|
||||
String url = hs + "/_matrix/client/v3/rooms/" + encPath(roomId) + "/send/m.room.message/" + encPath(txnId)
|
||||
+ "?access_token=" + enc(asToken)
|
||||
+ "&user_id=" + enc(mxid);
|
||||
|
||||
String body = "{\"msgtype\":\"m.text\",\"body\":\"" + json(bodyText) + "\"}";
|
||||
|
||||
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
|
||||
.timeout(Duration.ofSeconds(10))
|
||||
.header("Content-Type", "application/json")
|
||||
.PUT(HttpRequest.BodyPublishers.ofString(body))
|
||||
.build();
|
||||
|
||||
sendOkOrIgnore(req);
|
||||
}
|
||||
|
||||
private void sendIgnoreConflict(HttpRequest req) {
|
||||
try {
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
int c = res.statusCode();
|
||||
if (c == 200) {
|
||||
return;
|
||||
}
|
||||
if (c == 400 && res.body() != null && res.body().contains("M_USER_IN_USE")) {
|
||||
return;
|
||||
}
|
||||
if (c == 401 || c == 403) {
|
||||
throw new RuntimeException("Matrix auth failed: " + c + " " + res.body());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private void sendOkOrIgnore(HttpRequest req) {
|
||||
try {
|
||||
HttpResponse<String> res = http.send(req, HttpResponse.BodyHandlers.ofString());
|
||||
int c = res.statusCode();
|
||||
if (c >= 200 && c < 300) {
|
||||
return;
|
||||
}
|
||||
if (c == 401 || c == 403) {
|
||||
throw new RuntimeException("Matrix auth failed: " + c + " " + res.body());
|
||||
}
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static String localpart(String mxid) {
|
||||
// "@localpart:domain"
|
||||
int at = mxid.indexOf('@');
|
||||
int colon = mxid.indexOf(':');
|
||||
if (at != 0 || colon < 0) {
|
||||
throw new IllegalArgumentException("bad mxid: " + mxid);
|
||||
}
|
||||
return mxid.substring(1, colon);
|
||||
}
|
||||
|
||||
private static String enc(String s) {
|
||||
return URLEncoder.encode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String encPath(String s) {
|
||||
// safe enough for matrix path segments
|
||||
return URLEncoder.encode(s, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String json(String s) {
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.alttd.matrix.interfaces;
|
||||
|
||||
import java.io.Closeable;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
public interface MatrixAppServiceServer extends Closeable {
|
||||
void setTransactionListener(Consumer<TransactionEvent> listener);
|
||||
|
||||
record TransactionEvent(String roomId, String senderMxid, String body, String eventId) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
package com.alttd.matrix.interfaces;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
public interface MatrixBridge {
|
||||
void setInboundHandler(InboundHandler handler);
|
||||
|
||||
void setRoomResolver(RoomResolver resolver);
|
||||
|
||||
void sendChat(UUID playerUuid, String mcUsername, String serverName, String message);
|
||||
|
||||
interface InboundHandler {
|
||||
void onMatrixChat(String roomId, String senderMxid, String body);
|
||||
}
|
||||
|
||||
interface RoomResolver {
|
||||
String roomIdForServer(String serverName);
|
||||
|
||||
String serverForRoomId(String roomId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.alttd.matrix.interfaces;
|
||||
|
||||
public interface MatrixClient {
|
||||
void ensureUser(String mxid);
|
||||
|
||||
void setDisplayName(String mxid, String displayName);
|
||||
|
||||
void ensureJoined(String mxid, String roomId);
|
||||
|
||||
void sendText(String mxid, String roomId, String body);
|
||||
}
|
||||
Reference in New Issue
Block a user