@takosjp/yurucommu-core 3.2.1 → 3.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/migrations/0020_call_sessions.sql +23 -0
- package/package.json +2 -1
- package/packages/api/package.json +1 -1
- package/packages/api/src/index.ts +2 -0
- package/packages/api/src/lib/rtc-client.ts +540 -0
- package/packages/api/src/types/call.ts +298 -0
- package/packages/api/src/types/index.ts +6 -0
- package/packages/api/src/types/realtime.ts +139 -0
- package/src/backend/index.ts +32 -1
- package/src/backend/lib/delivery/queue.ts +4 -0
- package/src/backend/lib/rtc/call-store.ts +101 -0
- package/src/backend/lib/rtc/provider.ts +135 -0
- package/src/backend/lib/rtc/signal-transport.ts +125 -0
- package/src/backend/lib/unread-counts.ts +63 -2
- package/src/backend/public.ts +6 -0
- package/src/backend/routes/activitypub.ts +5 -1
- package/src/backend/routes/auth.ts +8 -2
- package/src/backend/routes/communities/messages.ts +53 -17
- package/src/backend/routes/dm/messages.ts +47 -7
- package/src/backend/routes/dm/read-archive.ts +27 -0
- package/src/backend/routes/dm/typing.ts +16 -0
- package/src/backend/routes/notifications.ts +25 -0
- package/src/backend/routes/realtime/index.ts +67 -0
- package/src/backend/routes/rtc/index.ts +151 -0
- package/src/backend/runtime/call-hub-core.ts +480 -0
- package/src/backend/runtime/call-hub-port.ts +78 -0
- package/src/backend/runtime/call-signaling-do.ts +242 -0
- package/src/backend/runtime/realtime-hub.ts +257 -0
- package/src/backend/runtime/realtime-stream-do.ts +323 -0
- package/src/backend/runtime/signaling-hub.ts +187 -0
- package/src/backend/types.ts +32 -0
- package/src/db/schema/calls.ts +46 -0
- package/src/db/schema/index.ts +1 -0
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RtcProvider — media-plane configuration for the call feature.
|
|
3
|
+
*
|
|
4
|
+
* This is the seam that keeps calling vendor-neutral: 1:1 calls are pure P2P
|
|
5
|
+
* WebRTC over the operator-configured STUN/TURN servers (no SFU, no Cloudflare),
|
|
6
|
+
* and group calls (Phase 3) select a WHIP/WHEP SFU "focus" whose backend is any
|
|
7
|
+
* of `whip` / `livekit` / `cloudflare-realtime` — Cloudflare Realtime is one
|
|
8
|
+
* adapter among equals, never required.
|
|
9
|
+
*
|
|
10
|
+
* TURN credentials, when coturn's REST scheme is configured, are minted
|
|
11
|
+
* per-request as short-lived HMAC creds (RFC 8489 long-term-credential via the
|
|
12
|
+
* `turn-rest` `timestamp:name` username convention) — no static long-lived
|
|
13
|
+
* secret is ever handed to a client.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import type {
|
|
17
|
+
CallMediaKind,
|
|
18
|
+
IceServerConfig,
|
|
19
|
+
SfuFocus,
|
|
20
|
+
} from "../../../../packages/api/src/types/call.ts";
|
|
21
|
+
import type { EnvVars } from "../../types.ts";
|
|
22
|
+
import { bufferToBase64 } from "../base64.ts";
|
|
23
|
+
import { logger } from "../logger.ts";
|
|
24
|
+
|
|
25
|
+
const log = logger.child({ component: "rtc.provider" });
|
|
26
|
+
|
|
27
|
+
const DEFAULT_TURN_TTL_SECONDS = 3600;
|
|
28
|
+
|
|
29
|
+
export interface RtcProvider {
|
|
30
|
+
/** ICE (STUN/TURN) servers for a call. Fresh (short-lived) TURN creds. */
|
|
31
|
+
getIceServers(): Promise<IceServerConfig[]>;
|
|
32
|
+
/** Group-call SFU focus, or null for pure P2P (always null for 1:1). */
|
|
33
|
+
getSfuFocus(media: CallMediaKind): Promise<SfuFocus | null>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function parseStaticIceServers(raw: string | undefined): IceServerConfig[] {
|
|
37
|
+
if (!raw?.trim()) return [];
|
|
38
|
+
try {
|
|
39
|
+
const parsed = JSON.parse(raw);
|
|
40
|
+
if (!Array.isArray(parsed)) return [];
|
|
41
|
+
const out: IceServerConfig[] = [];
|
|
42
|
+
for (const entry of parsed) {
|
|
43
|
+
if (!entry || typeof entry !== "object") continue;
|
|
44
|
+
const urls = (entry as { urls?: unknown }).urls;
|
|
45
|
+
if (typeof urls !== "string" && !Array.isArray(urls)) continue;
|
|
46
|
+
const server: IceServerConfig = { urls: urls as string | string[] };
|
|
47
|
+
const username = (entry as { username?: unknown }).username;
|
|
48
|
+
const credential = (entry as { credential?: unknown }).credential;
|
|
49
|
+
if (typeof username === "string") server.username = username;
|
|
50
|
+
if (typeof credential === "string") server.credential = credential;
|
|
51
|
+
out.push(server);
|
|
52
|
+
}
|
|
53
|
+
return out;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
log.warn("Invalid YURUCOMMU_RTC_ICE_SERVERS JSON", { error: String(err) });
|
|
56
|
+
return [];
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function mintTurnCredential(
|
|
61
|
+
uris: string[],
|
|
62
|
+
secret: string,
|
|
63
|
+
ttlSeconds: number,
|
|
64
|
+
): Promise<IceServerConfig | null> {
|
|
65
|
+
if (uris.length === 0 || !secret) return null;
|
|
66
|
+
// coturn REST: username = "<unix-expiry>:<name>", credential = base64(HMAC-
|
|
67
|
+
// SHA1(secret, username)). A random name keeps creds unlinkable per call.
|
|
68
|
+
const expiry = Math.floor(Date.now() / 1000) + ttlSeconds;
|
|
69
|
+
const username = `${expiry}:yurucommu`;
|
|
70
|
+
try {
|
|
71
|
+
const key = await crypto.subtle.importKey(
|
|
72
|
+
"raw",
|
|
73
|
+
new TextEncoder().encode(secret),
|
|
74
|
+
{ name: "HMAC", hash: "SHA-1" },
|
|
75
|
+
false,
|
|
76
|
+
["sign"],
|
|
77
|
+
);
|
|
78
|
+
const mac = await crypto.subtle.sign(
|
|
79
|
+
"HMAC",
|
|
80
|
+
key,
|
|
81
|
+
new TextEncoder().encode(username),
|
|
82
|
+
);
|
|
83
|
+
return { urls: uris, username, credential: bufferToBase64(mac) };
|
|
84
|
+
} catch (err) {
|
|
85
|
+
log.error("Failed to mint TURN credential", { error: String(err) });
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
class ConfiguredRtcProvider implements RtcProvider {
|
|
91
|
+
constructor(private readonly env: EnvVars) {}
|
|
92
|
+
|
|
93
|
+
async getIceServers(): Promise<IceServerConfig[]> {
|
|
94
|
+
const servers = parseStaticIceServers(this.env.YURUCOMMU_RTC_ICE_SERVERS);
|
|
95
|
+
const uris = (this.env.YURUCOMMU_RTC_TURN_URIS ?? "")
|
|
96
|
+
.split(",")
|
|
97
|
+
.map((s) => s.trim())
|
|
98
|
+
.filter(Boolean);
|
|
99
|
+
const secret = this.env.YURUCOMMU_RTC_TURN_SECRET?.trim();
|
|
100
|
+
if (uris.length > 0 && secret) {
|
|
101
|
+
const ttl =
|
|
102
|
+
Number.parseInt(this.env.YURUCOMMU_RTC_TURN_TTL ?? "", 10) ||
|
|
103
|
+
DEFAULT_TURN_TTL_SECONDS;
|
|
104
|
+
const turn = await mintTurnCredential(uris, secret, ttl);
|
|
105
|
+
if (turn) servers.push(turn);
|
|
106
|
+
}
|
|
107
|
+
return servers;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async getSfuFocus(_media: CallMediaKind): Promise<SfuFocus | null> {
|
|
111
|
+
const adapter = (this.env.YURUCOMMU_RTC_SFU_ADAPTER ?? "p2p")
|
|
112
|
+
.trim()
|
|
113
|
+
.toLowerCase();
|
|
114
|
+
if (adapter === "" || adapter === "p2p") return null;
|
|
115
|
+
const url = this.env.YURUCOMMU_RTC_SFU_URL?.trim();
|
|
116
|
+
if (!url) {
|
|
117
|
+
log.warn("SFU adapter selected but YURUCOMMU_RTC_SFU_URL unset", {
|
|
118
|
+
adapter,
|
|
119
|
+
});
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
// WHIP/WHEP focus passthrough. Real per-room token minting (LiveKit JWT,
|
|
123
|
+
// Cloudflare Realtime app tokens) lands with group calls (Phase 3); today
|
|
124
|
+
// the shared/static token (if any) is advertised as-is.
|
|
125
|
+
return {
|
|
126
|
+
kind: adapter,
|
|
127
|
+
url,
|
|
128
|
+
token: this.env.YURUCOMMU_RTC_SFU_TOKEN?.trim() || undefined,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function createRtcProvider(env: EnvVars): RtcProvider {
|
|
134
|
+
return new ConfiguredRtcProvider(env);
|
|
135
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-instance call signaling transport (server-to-server).
|
|
3
|
+
*
|
|
4
|
+
* Signaling for a call between two self-hosted instances travels as an
|
|
5
|
+
* HTTP-Signature-signed POST to the peer instance's `/ap/rtc/signal` endpoint.
|
|
6
|
+
* This deliberately does NOT use the queued federation delivery path
|
|
7
|
+
* (`enqueueDeliveryToActor`, with its circuit-breaker + retry/backoff): an SDP
|
|
8
|
+
* offer or ICE candidate is ephemeral and latency-sensitive, so a stale retry is
|
|
9
|
+
* useless. We reuse the same low-level signing (`signRequest`) + SSRF-guarded
|
|
10
|
+
* fetch (`fetchWithTimeout`) primitives the delivery worker uses, but send
|
|
11
|
+
* synchronously and directly. It also does NOT go through the inbox activity
|
|
12
|
+
* pipeline (`claimActivityForDispatch` / `parseActivity`), which would both strip
|
|
13
|
+
* the SDP/ICE fields and pollute the `activities` ledger with ephemeral frames.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { eq } from "drizzle-orm";
|
|
17
|
+
import type { Database } from "../../../db/index.ts";
|
|
18
|
+
import { actorCache } from "../../../db/index.ts";
|
|
19
|
+
import type { RtcSignalEnvelopeV1 } from "../../../../packages/api/src/types/call.ts";
|
|
20
|
+
import { signRequest } from "../ap-signing.ts";
|
|
21
|
+
import { fetchWithTimeout } from "../federation-fetch.ts";
|
|
22
|
+
import { fetchAndUpsertActorCache } from "../activitypub-actor-cache.ts";
|
|
23
|
+
import { isSafeRemoteUrl } from "../ssrf.ts";
|
|
24
|
+
import { logger } from "../logger.ts";
|
|
25
|
+
|
|
26
|
+
const log = logger.child({ component: "rtc.signal-transport" });
|
|
27
|
+
|
|
28
|
+
const SIGNAL_PATH = "/ap/rtc/signal";
|
|
29
|
+
const SIGNAL_TIMEOUT_MS = 8000;
|
|
30
|
+
|
|
31
|
+
export interface CallSigner {
|
|
32
|
+
apId: string;
|
|
33
|
+
privateKeyPem: string;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Derive a peer's signaling endpoint from a cached actor row. */
|
|
37
|
+
function endpointFromActorRow(row: {
|
|
38
|
+
inbox: string;
|
|
39
|
+
rawJson: string;
|
|
40
|
+
}): string | null {
|
|
41
|
+
// Prefer an explicitly advertised endpoint (actor `endpoints.rtcSignal`).
|
|
42
|
+
try {
|
|
43
|
+
const doc = JSON.parse(row.rawJson) as {
|
|
44
|
+
endpoints?: { rtcSignal?: unknown };
|
|
45
|
+
};
|
|
46
|
+
const advertised = doc.endpoints?.rtcSignal;
|
|
47
|
+
if (typeof advertised === "string" && isSafeRemoteUrl(advertised)) {
|
|
48
|
+
return advertised;
|
|
49
|
+
}
|
|
50
|
+
} catch {
|
|
51
|
+
// fall through to inbox-origin derivation
|
|
52
|
+
}
|
|
53
|
+
// Fall back to `<inbox-origin>/ap/rtc/signal` (every yurucommu instance
|
|
54
|
+
// serves this path). Peers that are not yurucommu simply won't answer.
|
|
55
|
+
try {
|
|
56
|
+
const origin = new URL(row.inbox).origin;
|
|
57
|
+
const endpoint = `${origin}${SIGNAL_PATH}`;
|
|
58
|
+
return isSafeRemoteUrl(endpoint) ? endpoint : null;
|
|
59
|
+
} catch {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Resolve (and cache) the peer instance's signaling endpoint URL. */
|
|
65
|
+
export async function resolvePeerSignalEndpoint(
|
|
66
|
+
db: Database,
|
|
67
|
+
peerApId: string,
|
|
68
|
+
): Promise<string | null> {
|
|
69
|
+
let row = await db.query.actorCache.findFirst({
|
|
70
|
+
where: eq(actorCache.apId, peerApId),
|
|
71
|
+
columns: { inbox: true, rawJson: true },
|
|
72
|
+
});
|
|
73
|
+
if (!row) {
|
|
74
|
+
const result = await fetchAndUpsertActorCache(db, peerApId, {});
|
|
75
|
+
if (result.ok) {
|
|
76
|
+
row = { inbox: result.row.inbox, rawJson: result.row.rawJson };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!row) return null;
|
|
80
|
+
return endpointFromActorRow(row);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Sign + POST a signaling envelope to the peer instance. Throws on any
|
|
85
|
+
* unreachable / non-2xx outcome so the caller can fail the call fast.
|
|
86
|
+
*/
|
|
87
|
+
export async function sendCallSignal(
|
|
88
|
+
db: Database,
|
|
89
|
+
signer: CallSigner,
|
|
90
|
+
envelope: RtcSignalEnvelopeV1,
|
|
91
|
+
peerSignalEndpoint?: string,
|
|
92
|
+
): Promise<void> {
|
|
93
|
+
const endpoint =
|
|
94
|
+
peerSignalEndpoint ?? (await resolvePeerSignalEndpoint(db, envelope.to));
|
|
95
|
+
if (!endpoint) {
|
|
96
|
+
throw new Error(`no signaling endpoint for ${envelope.to}`);
|
|
97
|
+
}
|
|
98
|
+
const body = JSON.stringify(envelope);
|
|
99
|
+
const keyId = `${signer.apId}#main-key`;
|
|
100
|
+
const signed = await signRequest(
|
|
101
|
+
signer.privateKeyPem,
|
|
102
|
+
keyId,
|
|
103
|
+
"POST",
|
|
104
|
+
endpoint,
|
|
105
|
+
body,
|
|
106
|
+
);
|
|
107
|
+
const res = await fetchWithTimeout(endpoint, {
|
|
108
|
+
method: "POST",
|
|
109
|
+
headers: {
|
|
110
|
+
...signed,
|
|
111
|
+
"Content-Type": "application/activity+json",
|
|
112
|
+
Accept: "application/json",
|
|
113
|
+
},
|
|
114
|
+
body,
|
|
115
|
+
timeout: SIGNAL_TIMEOUT_MS,
|
|
116
|
+
});
|
|
117
|
+
if (!res.ok) {
|
|
118
|
+
log.warn("Signaling POST rejected", {
|
|
119
|
+
callId: envelope.callId,
|
|
120
|
+
type: envelope.type,
|
|
121
|
+
status: res.status,
|
|
122
|
+
});
|
|
123
|
+
throw new Error(`signal POST ${res.status}`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
@@ -17,8 +17,14 @@
|
|
|
17
17
|
* own, after the later of the per-community read time and the join time.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import { sql } from "drizzle-orm";
|
|
21
|
-
import
|
|
20
|
+
import { and, count, eq, sql } from "drizzle-orm";
|
|
21
|
+
import {
|
|
22
|
+
activities,
|
|
23
|
+
inbox as inboxTable,
|
|
24
|
+
objects,
|
|
25
|
+
type Database,
|
|
26
|
+
} from "../../db/index.ts";
|
|
27
|
+
import { notificationEligibilityWhere } from "./notification-eligibility.ts";
|
|
22
28
|
|
|
23
29
|
export interface YurumeUnreadCounts {
|
|
24
30
|
readonly dm: number;
|
|
@@ -77,3 +83,58 @@ export async function yurumeUnreadCounts(
|
|
|
77
83
|
const community = Number(communityRow?.c ?? 0);
|
|
78
84
|
return { dm, community, total: dm + community };
|
|
79
85
|
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Unread social-notification count. SAME shared eligibility builder as the
|
|
89
|
+
* notifications list, the badge endpoint, and push delivery (not-self,
|
|
90
|
+
* user-facing types, archive exclusion, direct-DM exclusion, block/mute
|
|
91
|
+
* suppression) so a realtime-pushed badge can never drift from the badge the
|
|
92
|
+
* client fetches.
|
|
93
|
+
*/
|
|
94
|
+
export async function notificationUnreadCount(
|
|
95
|
+
db: Database,
|
|
96
|
+
actorApId: string,
|
|
97
|
+
): Promise<number> {
|
|
98
|
+
const result = await db
|
|
99
|
+
.select({ count: count() })
|
|
100
|
+
.from(inboxTable)
|
|
101
|
+
.innerJoin(activities, eq(inboxTable.activityApId, activities.apId))
|
|
102
|
+
.leftJoin(objects, eq(activities.objectApId, objects.apId))
|
|
103
|
+
.where(
|
|
104
|
+
and(
|
|
105
|
+
eq(inboxTable.actorApId, actorApId),
|
|
106
|
+
eq(inboxTable.read, 0),
|
|
107
|
+
...notificationEligibilityWhere(db, actorApId, { direct: "exclude" }),
|
|
108
|
+
),
|
|
109
|
+
)
|
|
110
|
+
.get();
|
|
111
|
+
return Number(result?.count ?? 0);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export interface UnreadSnapshot {
|
|
115
|
+
readonly dm: number;
|
|
116
|
+
readonly community: number;
|
|
117
|
+
readonly talkTotal: number;
|
|
118
|
+
readonly notifications: number;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* One authoritative unread snapshot (talk + notifications) for the realtime
|
|
123
|
+
* `unread` event. Server-computed on every emit so clients never derive or
|
|
124
|
+
* increment counters themselves.
|
|
125
|
+
*/
|
|
126
|
+
export async function computeUnreadSnapshot(
|
|
127
|
+
db: Database,
|
|
128
|
+
actorApId: string,
|
|
129
|
+
): Promise<UnreadSnapshot> {
|
|
130
|
+
const [talk, notifications] = await Promise.all([
|
|
131
|
+
yurumeUnreadCounts(db, actorApId),
|
|
132
|
+
notificationUnreadCount(db, actorApId),
|
|
133
|
+
]);
|
|
134
|
+
return {
|
|
135
|
+
dm: talk.dm,
|
|
136
|
+
community: talk.community,
|
|
137
|
+
talkTotal: talk.total,
|
|
138
|
+
notifications,
|
|
139
|
+
};
|
|
140
|
+
}
|
package/src/backend/public.ts
CHANGED
|
@@ -13,6 +13,12 @@ export { default } from "./index.ts";
|
|
|
13
13
|
export { default as app } from "./index.ts";
|
|
14
14
|
export { type Database, getDb, getDbSQLite } from "../db/index.ts";
|
|
15
15
|
export { wrapCloudflareBindings } from "./runtime/cloudflare.ts";
|
|
16
|
+
// Call feature: the signaling Durable Object class each product's generated
|
|
17
|
+
// worker entry must re-export so Wrangler can bind CALL_SIGNALING to it.
|
|
18
|
+
export { CallSignalingDurableObject } from "./runtime/call-signaling-do.ts";
|
|
19
|
+
// Realtime stream: the per-user fanout Durable Object class each product's
|
|
20
|
+
// generated worker entry must re-export so Wrangler can bind REALTIME_STREAM.
|
|
21
|
+
export { RealtimeStreamDO } from "./runtime/realtime-stream-do.ts";
|
|
16
22
|
export type { Env, EnvVars } from "./types.ts";
|
|
17
23
|
export type {
|
|
18
24
|
DeliveryDlqMessageV1,
|
|
@@ -440,9 +440,13 @@ ap.get(
|
|
|
440
440
|
following: showCollections ? actor.followingUrl : undefined,
|
|
441
441
|
// Advertise sharedInbox so remote servers can deduplicate fan-out
|
|
442
442
|
// delivery (Mastodon convention). The endpoint accepts signed
|
|
443
|
-
// activities just like the per-actor inbox.
|
|
443
|
+
// activities just like the per-actor inbox. `rtcSignal` advertises the
|
|
444
|
+
// call-signaling ingest so a caller's instance can reach this user
|
|
445
|
+
// directly (yurucommu extension; peers that don't recognize it just fall
|
|
446
|
+
// back to deriving it from the inbox origin).
|
|
444
447
|
endpoints: {
|
|
445
448
|
sharedInbox: `${baseUrl}/ap/inbox`,
|
|
449
|
+
rtcSignal: `${baseUrl}/ap/rtc/signal`,
|
|
446
450
|
},
|
|
447
451
|
publicKey: buildPublicKey(actor.apId, actor.publicKeyPem),
|
|
448
452
|
discoverable: !actor.isPrivate,
|
|
@@ -63,6 +63,12 @@ const auth = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
|
63
63
|
// abuse even from the legitimate owner session (#23).
|
|
64
64
|
const MAX_SUB_ACCOUNTS = 20;
|
|
65
65
|
|
|
66
|
+
function parsePassword(value: unknown): string | null {
|
|
67
|
+
// Passwords are opaque credentials. Trimming changes a valid secret and can
|
|
68
|
+
// make the native and browser login behavior diverge from the stored hash.
|
|
69
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
70
|
+
}
|
|
71
|
+
|
|
66
72
|
// 認証設定取得
|
|
67
73
|
auth.get("/providers", async (c) => {
|
|
68
74
|
const config = getAuthConfig(c.env);
|
|
@@ -151,7 +157,7 @@ auth.post("/login", async (c) => {
|
|
|
151
157
|
return c.json({ error: "Invalid request body", code: "BAD_REQUEST" }, 400);
|
|
152
158
|
}
|
|
153
159
|
|
|
154
|
-
const password =
|
|
160
|
+
const password = parsePassword(body.password);
|
|
155
161
|
if (!password) {
|
|
156
162
|
return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
|
|
157
163
|
}
|
|
@@ -233,7 +239,7 @@ auth.post("/mobile/login", async (c) => {
|
|
|
233
239
|
}
|
|
234
240
|
|
|
235
241
|
const body = await parseJsonObject(c);
|
|
236
|
-
const password = body ?
|
|
242
|
+
const password = body ? parsePassword(body.password) : null;
|
|
237
243
|
if (!password) {
|
|
238
244
|
return c.json({ error: "password is required", code: "BAD_REQUEST" }, 400);
|
|
239
245
|
}
|
|
@@ -23,6 +23,12 @@ import {
|
|
|
23
23
|
} from "../../lib/attachments.ts";
|
|
24
24
|
import { communityRequiresMembership } from "../../lib/community-visibility.ts";
|
|
25
25
|
import { rateLimit, RateLimitConfigs } from "../../middleware/rate-limit.ts";
|
|
26
|
+
import {
|
|
27
|
+
emitRealtimeBestEffort,
|
|
28
|
+
emitUnreadSnapshot,
|
|
29
|
+
isRealtimeAvailable,
|
|
30
|
+
runRealtimeAfterResponse,
|
|
31
|
+
} from "../../runtime/realtime-hub.ts";
|
|
26
32
|
import {
|
|
27
33
|
deleteObjectCascade,
|
|
28
34
|
purgeMediaBlobs,
|
|
@@ -408,24 +414,54 @@ messagesRouter.post(
|
|
|
408
414
|
...pushJobStatements,
|
|
409
415
|
]);
|
|
410
416
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
name: actor.name,
|
|
420
|
-
icon_url: actor.icon_url,
|
|
421
|
-
},
|
|
422
|
-
content,
|
|
423
|
-
attachments,
|
|
424
|
-
created_at: now,
|
|
425
|
-
},
|
|
417
|
+
const messagePayload = {
|
|
418
|
+
id: objectApId,
|
|
419
|
+
sender: {
|
|
420
|
+
ap_id: actor.ap_id,
|
|
421
|
+
username: formatUsername(actor.ap_id),
|
|
422
|
+
preferred_username: actor.preferred_username,
|
|
423
|
+
name: actor.name,
|
|
424
|
+
icon_url: actor.icon_url,
|
|
426
425
|
},
|
|
427
|
-
|
|
428
|
-
|
|
426
|
+
content,
|
|
427
|
+
attachments,
|
|
428
|
+
created_at: now,
|
|
429
|
+
};
|
|
430
|
+
|
|
431
|
+
// Realtime fanout to LOCAL members (best-effort, after the response).
|
|
432
|
+
// Community talk has no inbox row, so the shared notification sweep never
|
|
433
|
+
// sees it — this direct emit is the only realtime path. Membership rows
|
|
434
|
+
// exist only for local members (remote membership is a follows edge), and
|
|
435
|
+
// the fanout is capped so a huge community cannot stall the writer.
|
|
436
|
+
if (isRealtimeAvailable(c.env)) {
|
|
437
|
+
const communityApIdForEmit = community.apId;
|
|
438
|
+
await runRealtimeAfterResponse(c, async () => {
|
|
439
|
+
const members = await db
|
|
440
|
+
.select({ actorApId: communityMembers.actorApId })
|
|
441
|
+
.from(communityMembers)
|
|
442
|
+
.where(eq(communityMembers.communityApId, communityApIdForEmit))
|
|
443
|
+
.limit(200);
|
|
444
|
+
await emitRealtimeBestEffort(
|
|
445
|
+
c.env,
|
|
446
|
+
members.map(({ actorApId }) => ({
|
|
447
|
+
actorApId,
|
|
448
|
+
type: "talk.message",
|
|
449
|
+
data: {
|
|
450
|
+
kind: "community",
|
|
451
|
+
community_ap_id: communityApIdForEmit,
|
|
452
|
+
message: messagePayload,
|
|
453
|
+
},
|
|
454
|
+
})),
|
|
455
|
+
);
|
|
456
|
+
await Promise.all(
|
|
457
|
+
members
|
|
458
|
+
.filter(({ actorApId }) => actorApId !== actor.ap_id)
|
|
459
|
+
.map(({ actorApId }) => emitUnreadSnapshot(c.env, actorApId)),
|
|
460
|
+
);
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return c.json({ message: messagePayload }, 201);
|
|
429
465
|
},
|
|
430
466
|
);
|
|
431
467
|
|
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
resolveConversationId,
|
|
36
36
|
} from "./query-helpers.ts";
|
|
37
37
|
import { enqueueDeliveryToActor } from "../../lib/delivery/queue.ts";
|
|
38
|
+
import {
|
|
39
|
+
emitRealtimeBestEffort,
|
|
40
|
+
runRealtimeAfterResponse,
|
|
41
|
+
} from "../../runtime/realtime-hub.ts";
|
|
38
42
|
import { feedCursorWhere } from "../../lib/feed-cursor.ts";
|
|
39
43
|
import { toApAttachments } from "../../lib/activitypub-helpers.ts";
|
|
40
44
|
import { validateChatAttachments } from "../../lib/attachments.ts";
|
|
@@ -600,15 +604,51 @@ dm.post("/user/:encodedApId/messages", async (c) => {
|
|
|
600
604
|
await enqueueDeliveryToActor(c.env, deliveryActivityId, otherApId);
|
|
601
605
|
}
|
|
602
606
|
|
|
607
|
+
const messagePayload = {
|
|
608
|
+
id: apId,
|
|
609
|
+
sender: buildSenderFromActor(actor),
|
|
610
|
+
content,
|
|
611
|
+
attachments,
|
|
612
|
+
created_at: now,
|
|
613
|
+
};
|
|
614
|
+
|
|
615
|
+
// Realtime fanout (best-effort, after the response): the recipient's open
|
|
616
|
+
// thread gets the message body without polling; the sender's OTHER tabs and
|
|
617
|
+
// devices stay in sync too. `other_ap_id` is per-recipient (each side sees
|
|
618
|
+
// the counterpart). Unread counters flow via the shared post-response sweep
|
|
619
|
+
// (the inbox trigger wrote a push job for the local recipient).
|
|
620
|
+
await runRealtimeAfterResponse(c, () =>
|
|
621
|
+
emitRealtimeBestEffort(c.env, [
|
|
622
|
+
...(isRecipientLocal
|
|
623
|
+
? [
|
|
624
|
+
{
|
|
625
|
+
actorApId: otherApId,
|
|
626
|
+
type: "talk.message",
|
|
627
|
+
data: {
|
|
628
|
+
kind: "dm",
|
|
629
|
+
other_ap_id: actor.ap_id,
|
|
630
|
+
conversation_id: conversationId,
|
|
631
|
+
message: messagePayload,
|
|
632
|
+
},
|
|
633
|
+
},
|
|
634
|
+
]
|
|
635
|
+
: []),
|
|
636
|
+
{
|
|
637
|
+
actorApId: actor.ap_id,
|
|
638
|
+
type: "talk.message",
|
|
639
|
+
data: {
|
|
640
|
+
kind: "dm",
|
|
641
|
+
other_ap_id: otherApId,
|
|
642
|
+
conversation_id: conversationId,
|
|
643
|
+
message: messagePayload,
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
]),
|
|
647
|
+
);
|
|
648
|
+
|
|
603
649
|
return c.json(
|
|
604
650
|
{
|
|
605
|
-
message:
|
|
606
|
-
id: apId,
|
|
607
|
-
sender: buildSenderFromActor(actor),
|
|
608
|
-
content,
|
|
609
|
-
attachments,
|
|
610
|
-
created_at: now,
|
|
611
|
-
},
|
|
651
|
+
message: messagePayload,
|
|
612
652
|
conversation_id: conversationId,
|
|
613
653
|
},
|
|
614
654
|
201,
|
|
@@ -11,6 +11,11 @@ import {
|
|
|
11
11
|
objects,
|
|
12
12
|
} from "../../../db/index.ts";
|
|
13
13
|
import { resolveConversationId } from "./query-helpers.ts";
|
|
14
|
+
import {
|
|
15
|
+
emitRealtimeBestEffort,
|
|
16
|
+
emitUnreadSnapshot,
|
|
17
|
+
runRealtimeAfterResponse,
|
|
18
|
+
} from "../../runtime/realtime-hub.ts";
|
|
14
19
|
import {
|
|
15
20
|
buildActorInfoMap,
|
|
16
21
|
byTimeDesc,
|
|
@@ -54,6 +59,23 @@ readArchive.post("/user/:encodedApId/read", async (c) => {
|
|
|
54
59
|
set: { lastReadAt: now },
|
|
55
60
|
});
|
|
56
61
|
|
|
62
|
+
// Live read receipt for the partner's open thread + refreshed unread badge
|
|
63
|
+
// for the reader's OTHER tabs/devices.
|
|
64
|
+
await runRealtimeAfterResponse(c, async () => {
|
|
65
|
+
await emitRealtimeBestEffort(c.env, [
|
|
66
|
+
{
|
|
67
|
+
actorApId: otherApId,
|
|
68
|
+
type: "talk.read",
|
|
69
|
+
data: {
|
|
70
|
+
other_ap_id: actor.ap_id,
|
|
71
|
+
conversation_id: conversationId,
|
|
72
|
+
last_read_at: now,
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
await emitUnreadSnapshot(c.env, actor.ap_id);
|
|
77
|
+
});
|
|
78
|
+
|
|
57
79
|
return c.json({ success: true, last_read_at: now });
|
|
58
80
|
});
|
|
59
81
|
|
|
@@ -108,6 +130,11 @@ readArchive.post("/community/:encodedApId/read", async (c) => {
|
|
|
108
130
|
set: { lastReadAt: now },
|
|
109
131
|
});
|
|
110
132
|
|
|
133
|
+
// Refresh the reader's unread badge on their other tabs/devices.
|
|
134
|
+
await runRealtimeAfterResponse(c, () =>
|
|
135
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
136
|
+
);
|
|
137
|
+
|
|
111
138
|
return c.json({ success: true, last_read_at: now });
|
|
112
139
|
});
|
|
113
140
|
|
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { and, eq } from "drizzle-orm";
|
|
5
5
|
import { dmTyping } from "../../../db/index.ts";
|
|
6
|
+
import {
|
|
7
|
+
emitRealtimeBestEffort,
|
|
8
|
+
runRealtimeAfterResponse,
|
|
9
|
+
} from "../../runtime/realtime-hub.ts";
|
|
6
10
|
import { type HonoEnv, parseOtherApId } from "./conversations-helpers.ts";
|
|
7
11
|
|
|
8
12
|
const typing = new Hono<HonoEnv>();
|
|
@@ -28,6 +32,18 @@ typing.post("/user/:encodedApId/typing", async (c) => {
|
|
|
28
32
|
set: { lastTypedAt: now },
|
|
29
33
|
});
|
|
30
34
|
|
|
35
|
+
// Push the indicator to the partner's live sockets; the GET endpoint stays
|
|
36
|
+
// as the fallback-polling read.
|
|
37
|
+
await runRealtimeAfterResponse(c, () =>
|
|
38
|
+
emitRealtimeBestEffort(c.env, [
|
|
39
|
+
{
|
|
40
|
+
actorApId: otherApId,
|
|
41
|
+
type: "talk.typing",
|
|
42
|
+
data: { other_ap_id: actor.ap_id, is_typing: true, typed_at: now },
|
|
43
|
+
},
|
|
44
|
+
]),
|
|
45
|
+
);
|
|
46
|
+
|
|
31
47
|
return c.json({ success: true, typed_at: now });
|
|
32
48
|
});
|
|
33
49
|
|
|
@@ -35,6 +35,10 @@ import {
|
|
|
35
35
|
NOTIFICATION_ACTIVITY_TYPES,
|
|
36
36
|
notificationEligibilityWhere,
|
|
37
37
|
} from "../lib/notification-eligibility.ts";
|
|
38
|
+
import {
|
|
39
|
+
emitUnreadSnapshot,
|
|
40
|
+
runRealtimeAfterResponse,
|
|
41
|
+
} from "../runtime/realtime-hub.ts";
|
|
38
42
|
|
|
39
43
|
const notifications = new Hono<{ Bindings: Env; Variables: Variables }>();
|
|
40
44
|
|
|
@@ -622,6 +626,11 @@ notifications.post("/read", async (c) => {
|
|
|
622
626
|
);
|
|
623
627
|
}
|
|
624
628
|
|
|
629
|
+
// Sync the reader's OTHER tabs/devices: push the fresh authoritative badge.
|
|
630
|
+
await runRealtimeAfterResponse(c, () =>
|
|
631
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
632
|
+
);
|
|
633
|
+
|
|
625
634
|
return c.json({ success: true });
|
|
626
635
|
});
|
|
627
636
|
|
|
@@ -681,6 +690,11 @@ notifications.post("/archive", async (c) => {
|
|
|
681
690
|
ARCHIVE_CREATE_BATCH_SIZE,
|
|
682
691
|
);
|
|
683
692
|
|
|
693
|
+
// Archiving an unread notification removes it from the badge count.
|
|
694
|
+
await runRealtimeAfterResponse(c, () =>
|
|
695
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
696
|
+
);
|
|
697
|
+
|
|
684
698
|
return c.json({ success: true, archived_count });
|
|
685
699
|
});
|
|
686
700
|
|
|
@@ -716,6 +730,11 @@ notifications.delete("/archive", async (c) => {
|
|
|
716
730
|
),
|
|
717
731
|
);
|
|
718
732
|
|
|
733
|
+
// Unarchiving can resurface unread rows into the badge count.
|
|
734
|
+
await runRealtimeAfterResponse(c, () =>
|
|
735
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
736
|
+
);
|
|
737
|
+
|
|
719
738
|
return c.json({ success: true });
|
|
720
739
|
});
|
|
721
740
|
|
|
@@ -758,6 +777,12 @@ notifications.post("/archive/all", async (c) => {
|
|
|
758
777
|
rows,
|
|
759
778
|
ARCHIVE_CREATE_BATCH_SIZE,
|
|
760
779
|
);
|
|
780
|
+
|
|
781
|
+
// Archive-all clears the whole badge; sync the reader's other tabs/devices.
|
|
782
|
+
await runRealtimeAfterResponse(c, () =>
|
|
783
|
+
emitUnreadSnapshot(c.env, actor.ap_id),
|
|
784
|
+
);
|
|
785
|
+
|
|
761
786
|
return c.json({ success: true, archived_count });
|
|
762
787
|
});
|
|
763
788
|
|