mirror of
https://github.com/mautrix/whatsapp.git
synced 2026-08-24 13:04:56 -04:00
555 lines
16 KiB
Go
555 lines
16 KiB
Go
package voip
|
|
|
|
import (
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"time"
|
|
|
|
"maunium.net/go/mautrix/event"
|
|
"maunium.net/go/mautrix/id"
|
|
)
|
|
|
|
const (
|
|
EventTypeGroupCall = "org.matrix.msc3401.call"
|
|
EventTypeGroupCallMember = "org.matrix.msc3401.call.member"
|
|
EventTypeRTCMembership = "org.matrix.msc4143.rtc.member"
|
|
EventTypeRTCNotification = "org.matrix.msc4075.rtc.notification"
|
|
EventTypeCallNotify = "org.matrix.msc4075.call.notify"
|
|
EventTypeRTCDecline = "org.matrix.msc4310.rtc.decline"
|
|
EventTypeElementCallReaction = "io.element.call.reaction"
|
|
|
|
MatrixRTCApplicationCall = "m.call"
|
|
MatrixRTCDefaultSlotID = "m.call#ROOM"
|
|
MatrixRTCMembershipV1 = "org.matrix.msc4143.rtc.member"
|
|
)
|
|
|
|
var supportedMatrixRTCEventTypes = []event.Type{
|
|
{Type: EventTypeGroupCall, Class: event.StateEventType},
|
|
{Type: EventTypeGroupCallMember, Class: event.StateEventType},
|
|
{Type: EventTypeRTCMembership, Class: event.StateEventType},
|
|
{Type: EventTypeRTCMembership, Class: event.MessageEventType},
|
|
{Type: EventTypeRTCNotification, Class: event.MessageEventType},
|
|
{Type: EventTypeCallNotify, Class: event.MessageEventType},
|
|
{Type: EventTypeRTCDecline, Class: event.MessageEventType},
|
|
ElementCallReactionEventType(),
|
|
event.EventReaction,
|
|
event.EventRedaction,
|
|
}
|
|
|
|
type MatrixRTCEventKind string
|
|
|
|
const (
|
|
MatrixRTCEventKindUnknown MatrixRTCEventKind = ""
|
|
MatrixRTCEventKindGroupCall MatrixRTCEventKind = "group_call"
|
|
MatrixRTCEventKindGroupCallMember MatrixRTCEventKind = "group_call_member"
|
|
MatrixRTCEventKindRTCMembership MatrixRTCEventKind = "rtc_membership"
|
|
MatrixRTCEventKindRTCNotification MatrixRTCEventKind = "rtc_notification"
|
|
MatrixRTCEventKindLegacyCallNotify MatrixRTCEventKind = "legacy_call_notify"
|
|
MatrixRTCEventKindRTCDecline MatrixRTCEventKind = "rtc_decline"
|
|
MatrixRTCEventKindCallReaction MatrixRTCEventKind = "call_reaction"
|
|
MatrixRTCEventKindHandRaise MatrixRTCEventKind = "hand_raise"
|
|
MatrixRTCEventKindRedaction MatrixRTCEventKind = "redaction"
|
|
)
|
|
|
|
type MatrixRTCEvent struct {
|
|
Type event.Type
|
|
Kind MatrixRTCEventKind
|
|
RoomID id.RoomID
|
|
Sender id.UserID
|
|
StateKey string
|
|
CallID string
|
|
DeviceID string
|
|
SessionID string
|
|
Intent string
|
|
LifetimeMS int
|
|
FociPreferred []Focus
|
|
Raw map[string]any
|
|
EventID id.EventID
|
|
RelatesToEventID id.EventID
|
|
RelationType event.RelationType
|
|
RelationKey string
|
|
ReactionEmoji string
|
|
ReactionName string
|
|
Redacts id.EventID
|
|
}
|
|
|
|
type MatrixRTCSession struct {
|
|
UserID id.UserID
|
|
DeviceID string
|
|
MemberID string
|
|
CallID string
|
|
Intent string
|
|
Focus Focus
|
|
Created time.Time
|
|
Expires time.Duration
|
|
StickyKey string
|
|
NotificationEventID id.EventID
|
|
MembershipEventID id.EventID
|
|
}
|
|
|
|
func SupportedMatrixRTCEventTypes() []event.Type {
|
|
return append([]event.Type(nil), supportedMatrixRTCEventTypes...)
|
|
}
|
|
|
|
func ClassifyMatrixRTCEventType(evtType event.Type) MatrixRTCEventKind {
|
|
switch evtType.Type {
|
|
case EventTypeGroupCall:
|
|
return MatrixRTCEventKindGroupCall
|
|
case EventTypeGroupCallMember:
|
|
return MatrixRTCEventKindGroupCallMember
|
|
case EventTypeRTCMembership:
|
|
return MatrixRTCEventKindRTCMembership
|
|
case EventTypeRTCNotification:
|
|
return MatrixRTCEventKindRTCNotification
|
|
case EventTypeCallNotify:
|
|
return MatrixRTCEventKindLegacyCallNotify
|
|
case EventTypeRTCDecline:
|
|
return MatrixRTCEventKindRTCDecline
|
|
case EventTypeElementCallReaction:
|
|
return MatrixRTCEventKindCallReaction
|
|
case event.EventReaction.Type:
|
|
return MatrixRTCEventKindHandRaise
|
|
case event.EventRedaction.Type:
|
|
return MatrixRTCEventKindRedaction
|
|
default:
|
|
return MatrixRTCEventKindUnknown
|
|
}
|
|
}
|
|
|
|
func ParseMatrixRTCEvent(evt *event.Event) (MatrixRTCEvent, bool) {
|
|
if evt == nil {
|
|
return MatrixRTCEvent{}, false
|
|
}
|
|
kind := ClassifyMatrixRTCEventType(evt.Type)
|
|
if kind == MatrixRTCEventKindUnknown {
|
|
return MatrixRTCEvent{}, false
|
|
}
|
|
raw := rawMatrixRTCContent(evt)
|
|
parsed := MatrixRTCEvent{
|
|
Type: evt.Type,
|
|
Kind: kind,
|
|
RoomID: evt.RoomID,
|
|
Sender: evt.Sender,
|
|
EventID: evt.ID,
|
|
Raw: raw,
|
|
}
|
|
if evt.StateKey != nil {
|
|
parsed.StateKey = *evt.StateKey
|
|
}
|
|
fillMatrixRTCFields(&parsed, raw)
|
|
if evt.Redacts != "" {
|
|
parsed.Redacts = evt.Redacts
|
|
}
|
|
return parsed, true
|
|
}
|
|
|
|
func rawMatrixRTCContent(evt *event.Event) map[string]any {
|
|
if evt.Content.Raw != nil {
|
|
return evt.Content.Raw
|
|
}
|
|
if len(evt.Content.VeryRaw) > 0 {
|
|
var raw map[string]any
|
|
if err := json.Unmarshal(evt.Content.VeryRaw, &raw); err == nil && raw != nil {
|
|
return raw
|
|
}
|
|
}
|
|
if evt.Content.Parsed != nil {
|
|
data, err := json.Marshal(evt.Content.Parsed)
|
|
if err == nil {
|
|
var raw map[string]any
|
|
if err = json.Unmarshal(data, &raw); err == nil && raw != nil {
|
|
return raw
|
|
}
|
|
}
|
|
}
|
|
return map[string]any{}
|
|
}
|
|
|
|
func fillMatrixRTCFields(parsed *MatrixRTCEvent, raw map[string]any) {
|
|
parsed.CallID = firstString(raw, "call_id", "m.call_id", "callId", "callID")
|
|
parsed.DeviceID = firstString(raw, "device_id", "m.device_id", "deviceId", "deviceID")
|
|
parsed.SessionID = firstString(raw, "session_id", "m.session_id", "sessionId", "sessionID")
|
|
parsed.Intent = firstString(raw, "intent", "m.call.intent", "call_intent")
|
|
parsed.LifetimeMS = firstInt(raw, "lifetime", "lifetime_ms", "m.lifetime", "m.lifetime_ms")
|
|
if relatesTo, ok := raw["m.relates_to"].(map[string]any); ok {
|
|
parsed.RelatesToEventID = id.EventID(firstString(relatesTo, "event_id"))
|
|
parsed.RelationType = event.RelationType(firstString(relatesTo, "rel_type"))
|
|
parsed.RelationKey = firstString(relatesTo, "key")
|
|
}
|
|
parsed.ReactionEmoji = firstString(raw, "emoji")
|
|
parsed.ReactionName = firstString(raw, "name")
|
|
if parsed.Redacts == "" {
|
|
parsed.Redacts = id.EventID(firstString(raw, "redacts"))
|
|
}
|
|
forEachObject(raw["application"], func(application map[string]any) {
|
|
if parsed.Intent == "" {
|
|
parsed.Intent = firstString(application, "intent", "m.call.intent", "call_intent")
|
|
}
|
|
})
|
|
parsed.FociPreferred = append(parsed.FociPreferred, parseFoci(raw["rtc_transports"])...)
|
|
parsed.FociPreferred = append(parsed.FociPreferred, parseFoci(raw["foci_preferred"])...)
|
|
parsed.FociPreferred = append(parsed.FociPreferred, parseFoci(raw["m.foci_preferred"])...)
|
|
|
|
forEachObject(raw["memberships"], func(membership map[string]any) {
|
|
if parsed.CallID == "" {
|
|
parsed.CallID = firstString(membership, "call_id", "m.call_id", "callId", "callID")
|
|
}
|
|
if parsed.DeviceID == "" {
|
|
parsed.DeviceID = firstString(membership, "device_id", "m.device_id", "deviceId", "deviceID")
|
|
}
|
|
if parsed.SessionID == "" {
|
|
parsed.SessionID = firstString(membership, "session_id", "m.session_id", "sessionId", "sessionID")
|
|
}
|
|
if parsed.Intent == "" {
|
|
parsed.Intent = firstString(membership, "intent", "m.call.intent", "call_intent")
|
|
}
|
|
if parsed.LifetimeMS == 0 {
|
|
parsed.LifetimeMS = firstInt(membership, "lifetime", "lifetime_ms", "m.lifetime", "m.lifetime_ms")
|
|
}
|
|
forEachObject(membership["application"], func(application map[string]any) {
|
|
if parsed.Intent == "" {
|
|
parsed.Intent = firstString(application, "intent", "m.call.intent", "call_intent")
|
|
}
|
|
})
|
|
parsed.FociPreferred = append(parsed.FociPreferred, parseFoci(membership["rtc_transports"])...)
|
|
parsed.FociPreferred = append(parsed.FociPreferred, parseFoci(membership["foci_preferred"])...)
|
|
parsed.FociPreferred = append(parsed.FociPreferred, parseFoci(membership["m.foci_preferred"])...)
|
|
})
|
|
|
|
if parsed.DeviceID == "" {
|
|
parsed.DeviceID = parsed.StateKey
|
|
}
|
|
}
|
|
|
|
func firstString(raw map[string]any, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value, ok := raw[key]; ok {
|
|
if str, ok := value.(string); ok {
|
|
return str
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func firstInt(raw map[string]any, keys ...string) int {
|
|
for _, key := range keys {
|
|
value, ok := raw[key]
|
|
if !ok {
|
|
continue
|
|
}
|
|
switch typed := value.(type) {
|
|
case int:
|
|
return typed
|
|
case int64:
|
|
return int(typed)
|
|
case float64:
|
|
return int(typed)
|
|
case json.Number:
|
|
if integer, err := typed.Int64(); err == nil {
|
|
return int(integer)
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func MatrixRTCEventHasJoinContent(evt MatrixRTCEvent) bool {
|
|
switch evt.Kind {
|
|
case MatrixRTCEventKindRTCMembership, MatrixRTCEventKindGroupCallMember:
|
|
return matrixRTCContentHasJoinData(evt.Raw)
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func matrixRTCContentHasJoinData(raw map[string]any) bool {
|
|
if len(raw) == 0 {
|
|
return false
|
|
}
|
|
if matrixRTCModernContentHasJoinData(raw) || matrixRTCLegacyContentHasJoinData(raw) {
|
|
return true
|
|
}
|
|
hasJoin := false
|
|
forEachObject(raw["memberships"], func(membership map[string]any) {
|
|
if matrixRTCMembershipArrayItemHasJoinData(membership) {
|
|
hasJoin = true
|
|
}
|
|
})
|
|
return hasJoin
|
|
}
|
|
|
|
func matrixRTCModernContentHasJoinData(raw map[string]any) bool {
|
|
if slotID := firstString(raw, "slot_id"); slotID != "" && slotID != MatrixRTCDefaultSlotID {
|
|
return false
|
|
}
|
|
return matrixRTCApplicationIsCall(raw["application"]) &&
|
|
matrixRTCContentHasMember(raw) &&
|
|
len(parseFoci(raw["rtc_transports"])) > 0
|
|
}
|
|
|
|
func matrixRTCLegacyContentHasJoinData(raw map[string]any) bool {
|
|
if !matrixRTCApplicationIsCall(raw["application"]) ||
|
|
!matrixRTCContentHasIdentifier(raw) ||
|
|
!matrixRTCContentHasPositiveLifetime(raw) {
|
|
return false
|
|
}
|
|
return len(parseFoci(raw["foci_preferred"])) > 0 ||
|
|
len(parseFoci(raw["m.foci_preferred"])) > 0
|
|
}
|
|
|
|
func matrixRTCMembershipArrayItemHasJoinData(raw map[string]any) bool {
|
|
if application, ok := raw["application"]; ok && !matrixRTCApplicationIsCall(application) {
|
|
return false
|
|
}
|
|
if !matrixRTCContentHasIdentifier(raw) || !matrixRTCContentHasPositiveLifetime(raw) {
|
|
return false
|
|
}
|
|
return len(parseFoci(raw["rtc_transports"])) > 0 ||
|
|
len(parseFoci(raw["foci_preferred"])) > 0 ||
|
|
len(parseFoci(raw["m.foci_preferred"])) > 0
|
|
}
|
|
|
|
func matrixRTCApplicationIsCall(value any) bool {
|
|
switch typed := value.(type) {
|
|
case string:
|
|
return typed == MatrixRTCApplicationCall
|
|
case map[string]any:
|
|
return firstString(typed, "type", "application") == MatrixRTCApplicationCall
|
|
case []any:
|
|
for _, item := range typed {
|
|
if matrixRTCApplicationIsCall(item) {
|
|
return true
|
|
}
|
|
}
|
|
case []map[string]any:
|
|
for _, item := range typed {
|
|
if matrixRTCApplicationIsCall(item) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func matrixRTCContentHasMember(raw map[string]any) bool {
|
|
hasMember := false
|
|
forEachObject(raw["member"], func(member map[string]any) {
|
|
if firstString(member, "user_id", "device_id", "id") != "" {
|
|
hasMember = true
|
|
}
|
|
})
|
|
return hasMember
|
|
}
|
|
|
|
func matrixRTCContentHasIdentifier(raw map[string]any) bool {
|
|
return matrixRTCContentHasMember(raw) ||
|
|
firstString(raw, "membershipID", "membership_id", "device_id", "m.device_id", "deviceId", "deviceID", "session_id", "m.session_id", "sessionId", "sessionID") != ""
|
|
}
|
|
|
|
func matrixRTCContentHasPositiveLifetime(raw map[string]any) bool {
|
|
for _, key := range []string{"expires", "lifetime", "lifetime_ms", "m.lifetime", "m.lifetime_ms"} {
|
|
if _, ok := raw[key]; ok {
|
|
return firstInt(raw, key) > 0
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func forEachObject(value any, fn func(map[string]any)) {
|
|
switch typed := value.(type) {
|
|
case []any:
|
|
for _, item := range typed {
|
|
if object, ok := item.(map[string]any); ok {
|
|
fn(object)
|
|
}
|
|
}
|
|
case []map[string]any:
|
|
for _, item := range typed {
|
|
fn(item)
|
|
}
|
|
case map[string]any:
|
|
fn(typed)
|
|
}
|
|
}
|
|
|
|
func parseFoci(value any) []Focus {
|
|
var output []Focus
|
|
forEachObject(value, func(rawFocus map[string]any) {
|
|
if firstString(rawFocus, "type") != "livekit" {
|
|
return
|
|
}
|
|
serviceURL := firstString(rawFocus, "livekit_service_url", "livekit_service_url_prefix", "service_url")
|
|
if serviceURL == "" {
|
|
return
|
|
}
|
|
output = append(output, Focus{
|
|
Type: "livekit",
|
|
LiveKitServiceURL: serviceURL,
|
|
})
|
|
})
|
|
return output
|
|
}
|
|
|
|
func MatrixRTCDeviceID(loginID, waCallID string) string {
|
|
sum := sha256.Sum256([]byte(loginID + "\x00" + waCallID))
|
|
return "WA" + hex.EncodeToString(sum[:8])
|
|
}
|
|
|
|
func MatrixRTCMemberID(userID id.UserID, deviceID string) string {
|
|
if deviceID == "" {
|
|
return userID.String()
|
|
}
|
|
return userID.String() + ":" + deviceID
|
|
}
|
|
|
|
func MatrixRTCStateKey(userID id.UserID, deviceID string) string {
|
|
if deviceID == "" {
|
|
return userID.String()
|
|
}
|
|
return userID.String() + "_" + deviceID
|
|
}
|
|
|
|
func RTCMembershipEventType(class event.TypeClass) event.Type {
|
|
return event.Type{Type: EventTypeRTCMembership, Class: class}
|
|
}
|
|
|
|
func GroupCallMemberEventType() event.Type {
|
|
return event.Type{Type: EventTypeGroupCallMember, Class: event.StateEventType}
|
|
}
|
|
|
|
func RTCNotificationEventType() event.Type {
|
|
return event.Type{Type: EventTypeRTCNotification, Class: event.MessageEventType}
|
|
}
|
|
|
|
func LegacyCallNotifyEventType() event.Type {
|
|
return event.Type{Type: EventTypeCallNotify, Class: event.MessageEventType}
|
|
}
|
|
|
|
func BuildRTCMembershipContent(session MatrixRTCSession) map[string]any {
|
|
deviceID := session.DeviceID
|
|
memberID := session.MemberID
|
|
if memberID == "" {
|
|
memberID = MatrixRTCMemberID(session.UserID, deviceID)
|
|
}
|
|
stickyKey := session.StickyKey
|
|
if stickyKey == "" {
|
|
stickyKey = memberID
|
|
}
|
|
intent := session.Intent
|
|
if intent == "" {
|
|
intent = "audio"
|
|
}
|
|
application := map[string]any{
|
|
"type": MatrixRTCApplicationCall,
|
|
"m.call.intent": intent,
|
|
}
|
|
content := map[string]any{
|
|
"slot_id": MatrixRTCDefaultSlotID,
|
|
"member": map[string]any{
|
|
"user_id": session.UserID.String(),
|
|
"device_id": deviceID,
|
|
"id": memberID,
|
|
},
|
|
"application": application,
|
|
"rtc_transports": []map[string]any{liveKitTransport(session.Focus)},
|
|
"versions": []string{MatrixRTCMembershipV1},
|
|
"sticky_key": stickyKey,
|
|
"msc4354_sticky_key": stickyKey,
|
|
}
|
|
if session.NotificationEventID != "" {
|
|
content["m.relates_to"] = map[string]any{
|
|
"rel_type": "m.reference",
|
|
"event_id": session.NotificationEventID.String(),
|
|
}
|
|
}
|
|
return content
|
|
}
|
|
|
|
func BuildLegacyCallMemberContent(session MatrixRTCSession) map[string]any {
|
|
deviceID := session.DeviceID
|
|
memberID := session.MemberID
|
|
if memberID == "" {
|
|
memberID = MatrixRTCMemberID(session.UserID, deviceID)
|
|
}
|
|
intent := session.Intent
|
|
if intent == "" {
|
|
intent = "audio"
|
|
}
|
|
created := session.Created
|
|
if created.IsZero() {
|
|
created = time.Now()
|
|
}
|
|
expires := session.Expires
|
|
if expires <= 0 {
|
|
expires = 4 * time.Hour
|
|
}
|
|
return map[string]any{
|
|
"application": MatrixRTCApplicationCall,
|
|
"call_id": "",
|
|
"device_id": deviceID,
|
|
"focus_active": map[string]any{
|
|
"type": "livekit",
|
|
"focus_selection": "multi_sfu",
|
|
},
|
|
"foci_preferred": []map[string]any{liveKitTransport(session.Focus)},
|
|
"created_ts": created.UnixMilli(),
|
|
"scope": "m.room",
|
|
"expires": expires.Milliseconds(),
|
|
"m.call.intent": intent,
|
|
"membershipID": memberID,
|
|
}
|
|
}
|
|
|
|
func BuildRTCNotificationContent(now time.Time, lifetime time.Duration, intent string) map[string]any {
|
|
if now.IsZero() {
|
|
now = time.Now()
|
|
}
|
|
if lifetime <= 0 || lifetime > 90*time.Second {
|
|
lifetime = 90 * time.Second
|
|
}
|
|
if intent == "" {
|
|
intent = "audio"
|
|
}
|
|
return map[string]any{
|
|
"notification_type": "ring",
|
|
"sender_ts": now.UnixMilli(),
|
|
"lifetime": lifetime.Milliseconds(),
|
|
"m.call.intent": intent,
|
|
"m.mentions": map[string]any{},
|
|
}
|
|
}
|
|
|
|
func BuildLegacyCallNotifyContent(callID, intent string) map[string]any {
|
|
if intent == "" {
|
|
intent = "audio"
|
|
}
|
|
return map[string]any{
|
|
"application": MatrixRTCApplicationCall,
|
|
"notify_type": "ring",
|
|
"call_id": callID,
|
|
"m.call.intent": intent,
|
|
"m.mentions": map[string]any{},
|
|
}
|
|
}
|
|
|
|
func EmptyMatrixRTCContent(stickyKey string) map[string]any {
|
|
if stickyKey == "" {
|
|
return map[string]any{}
|
|
}
|
|
return map[string]any{
|
|
"sticky_key": stickyKey,
|
|
"msc4354_sticky_key": stickyKey,
|
|
}
|
|
}
|
|
|
|
func liveKitTransport(focus Focus) map[string]any {
|
|
transport := map[string]any{
|
|
"type": "livekit",
|
|
}
|
|
if focus.LiveKitServiceURL != "" {
|
|
transport["livekit_service_url"] = focus.LiveKitServiceURL
|
|
}
|
|
return transport
|
|
}
|