Merge branch 'master' into better-receipts-msc2409

This commit is contained in:
Andrew Ferrazzutti 2021-06-16 22:14:26 -04:00
commit a3e7caac27
7 changed files with 139 additions and 41 deletions

View File

@ -192,9 +192,7 @@ class Portal(DBPortal, BasePortal):
async def _bridge_own_message_pm(self, source: 'u.User', sender: Optional['p.Puppet'], mid: str, async def _bridge_own_message_pm(self, source: 'u.User', sender: Optional['p.Puppet'], mid: str,
invite: bool = True) -> Optional[IntentAPI]: invite: bool = True) -> Optional[IntentAPI]:
# Use bridge bot as puppet for own user when puppet for own user is unavailable intent = sender.intent if sender else (await source.get_own_puppet()).intent
# TODO Use own LINE puppet instead, and create it if it's not available yet
intent = sender.intent if sender else self.az.intent
if self.is_direct and (sender is None or sender.mid == source.mid and not sender.is_real_user): if self.is_direct and (sender is None or sender.mid == source.mid and not sender.is_real_user):
if self.invite_own_puppet_to_pm and invite: if self.invite_own_puppet_to_pm and invite:
try: try:
@ -228,7 +226,7 @@ class Portal(DBPortal, BasePortal):
if not self.invite_own_puppet_to_pm: if not self.invite_own_puppet_to_pm:
self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled") self.log.warning(f"Ignoring message {evt.id}: double puppeting isn't enabled")
return return
sender = p.Puppet.get_by_mid(evt.sender.id) if not self.is_direct else None sender = await p.Puppet.get_by_mid(evt.sender.id) if evt.sender else None
intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}") intent = await self._bridge_own_message_pm(source, sender, f"message {evt.id}")
if not intent: if not intent:
return return
@ -552,11 +550,12 @@ class Portal(DBPortal, BasePortal):
continue continue
mid = p.Puppet.get_id_from_mxid(user_id) mid = p.Puppet.get_id_from_mxid(user_id)
if mid and mid not in current_members: is_own_puppet = p.Puppet.is_mid_for_own_puppet(mid)
if mid and mid not in current_members and not is_own_puppet:
print(mid) print(mid)
await self.main_intent.kick_user(self.mxid, user_id, await self.main_intent.kick_user(self.mxid, user_id,
reason="User had left this chat") reason="User had left this chat")
elif forbid_own_puppets and p.Puppet.is_mid_for_own_puppet(mid): elif forbid_own_puppets and is_own_puppet:
await self.main_intent.kick_user(self.mxid, user_id, await self.main_intent.kick_user(self.mxid, user_id,
reason="Kicking own puppet") reason="Kicking own puppet")

View File

@ -19,7 +19,7 @@ from base64 import b64decode
import asyncio import asyncio
from .rpc import RPCClient from .rpc import RPCClient
from .types import ChatListInfo, ChatInfo, Message, Receipt, ImageData, StartStatus from .types import ChatListInfo, ChatInfo, ImageData, Message, Participant, Receipt, StartStatus
class LoginCommand(TypedDict): class LoginCommand(TypedDict):
@ -41,6 +41,9 @@ class Client(RPCClient):
async def resume(self) -> None: async def resume(self) -> None:
await self.request("resume") await self.request("resume")
async def get_own_profile(self) -> Participant:
return Participant.deserialize(await self.request("get_own_profile"))
async def get_chats(self) -> List[ChatListInfo]: async def get_chats(self) -> List[ChatListInfo]:
resp = await self.request("get_chats") resp = await self.request("get_chats")
return [ChatListInfo.deserialize(data) for data in resp] return [ChatListInfo.deserialize(data) for data in resp]

View File

@ -40,10 +40,11 @@ class RPCClient:
_response_waiters: Dict[int, asyncio.Future] _response_waiters: Dict[int, asyncio.Future]
_event_handlers: Dict[str, List[EventHandler]] _event_handlers: Dict[str, List[EventHandler]]
def __init__(self, user_id: UserID) -> None: def __init__(self, user_id: UserID, own_id: str) -> None:
self.log = self.log.getChild(user_id) self.log = self.log.getChild(user_id)
self.loop = asyncio.get_running_loop() self.loop = asyncio.get_running_loop()
self.user_id = user_id self.user_id = user_id
self.own_id = own_id
self._req_id = 0 self._req_id = 0
self._min_broadcast_id = 0 self._min_broadcast_id = 0
self._event_handlers = {} self._event_handlers = {}
@ -67,7 +68,9 @@ class RPCClient:
self._writer = w self._writer = w
self.loop.create_task(self._try_read_loop()) self.loop.create_task(self._try_read_loop())
self.loop.create_task(self._command_loop()) self.loop.create_task(self._command_loop())
await self.request("register", user_id=self.user_id) await self.request("register",
user_id=self.user_id,
own_id = self.own_id)
async def disconnect(self) -> None: async def disconnect(self) -> None:
self._writer.write_eof() self._writer.write_eof()

View File

@ -69,6 +69,14 @@ class User(DBUser, BaseUser):
self.log.debug(f"Sending bridge notice: {text}") self.log.debug(f"Sending bridge notice: {text}")
await self.az.intent.send_notice(self.notice_room, text) await self.az.intent.send_notice(self.notice_room, text)
@property
def own_id(self) -> str:
# Remove characters that will conflict with mxid grammar
return f"_OWN_{self.mxid[1:].replace(':', '_ON_')}"
async def get_own_puppet(self) -> 'pu.Puppet':
return await pu.Puppet.get_by_mid(self.own_id)
async def is_logged_in(self) -> bool: async def is_logged_in(self) -> bool:
try: try:
return self.client and (await self.client.start()).is_logged_in return self.client and (await self.client.start()).is_logged_in
@ -95,7 +103,7 @@ class User(DBUser, BaseUser):
async def connect(self) -> None: async def connect(self) -> None:
self.loop.create_task(self.connect_double_puppet()) self.loop.create_task(self.connect_double_puppet())
self.client = Client(self.mxid) self.client = Client(self.mxid, self.own_id)
self.log.debug("Starting client") self.log.debug("Starting client")
await self.send_bridge_notice("Starting up...") await self.send_bridge_notice("Starting up...")
state = await self.client.start() state = await self.client.start()
@ -126,6 +134,7 @@ class User(DBUser, BaseUser):
self._connection_check_task.cancel() self._connection_check_task.cancel()
self._connection_check_task = self.loop.create_task(self._check_connection_loop()) self._connection_check_task = self.loop.create_task(self._check_connection_loop())
await self.client.pause() await self.client.pause()
await self.sync_own_profile()
await self.client.set_last_message_ids(await DBMessage.get_max_mids()) await self.client.set_last_message_ids(await DBMessage.get_max_mids())
limit = self.config["bridge.initial_conversation_sync"] limit = self.config["bridge.initial_conversation_sync"]
self.log.info("Syncing chats") self.log.info("Syncing chats")
@ -152,6 +161,12 @@ class User(DBUser, BaseUser):
await portal.update_matrix_room(self, chat) await portal.update_matrix_room(self, chat)
await self.client.resume() await self.client.resume()
async def sync_own_profile(self) -> None:
self.log.info("Syncing own LINE profile info")
own_profile = await self.client.get_own_profile()
puppet = await self.get_own_puppet()
await puppet.update_info(own_profile, self.client)
async def stop(self) -> None: async def stop(self) -> None:
# TODO Notices for shutdown messages # TODO Notices for shutdown messages
if self._connection_check_task: if self._connection_check_task:

View File

@ -164,7 +164,7 @@ export default class Client {
let started = false let started = false
if (this.puppet === null) { if (this.puppet === null) {
this.log("Opening new puppeteer for", this.userID) this.log("Opening new puppeteer for", this.userID)
this.puppet = new MessagesPuppeteer(this.userID, this) this.puppet = new MessagesPuppeteer(this.userID, this.ownID, this)
this.manager.puppets.set(this.userID, this.puppet) this.manager.puppets.set(this.userID, this.puppet)
await this.puppet.start(!!req.debug) await this.puppet.start(!!req.debug)
started = true started = true
@ -194,11 +194,12 @@ export default class Client {
handleRegister = async (req) => { handleRegister = async (req) => {
this.userID = req.user_id this.userID = req.user_id
this.log("Registered socket", this.connID, "->", this.userID) this.ownID = req.own_id
this.log(`Registered socket ${this.connID} -> ${this.userID}`)
if (this.manager.clients.has(this.userID)) { if (this.manager.clients.has(this.userID)) {
const oldClient = this.manager.clients.get(this.userID) const oldClient = this.manager.clients.get(this.userID)
this.manager.clients.set(this.userID, this) this.manager.clients.set(this.userID, this)
this.log("Terminating previous socket", oldClient.connID, "for", this.userID) this.log(`Terminating previous socket ${oldClient.connID} for ${this.userID}`)
await oldClient.stop("Socket replaced by new connection") await oldClient.stop("Socket replaced by new connection")
} else { } else {
this.manager.clients.set(this.userID, this) this.manager.clients.set(this.userID, this)
@ -258,6 +259,7 @@ export default class Client {
set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids), set_last_message_ids: req => this.puppet.setLastMessageIDs(req.msg_ids),
pause: () => this.puppet.stopObserving(), pause: () => this.puppet.stopObserving(),
resume: () => this.puppet.startObserving(), resume: () => this.puppet.startObserving(),
get_own_profile: () => this.puppet.getOwnProfile(),
get_chats: () => this.puppet.getRecentChats(), get_chats: () => this.puppet.getRecentChats(),
get_chat: req => this.puppet.getChatInfo(req.chat_id, req.force_view), get_chat: req => this.puppet.getChatInfo(req.chat_id, req.force_view),
get_messages: req => this.puppet.getMessages(req.chat_id), get_messages: req => this.puppet.getMessages(req.chat_id),

View File

@ -102,9 +102,7 @@ class MautrixController {
} }
setOwnID(ownID) { setOwnID(ownID) {
// Remove characters that will conflict with mxid grammar this.ownID = ownID
const suffix = ownID.slice(1).replace(":", "_ON_")
this.ownID = `_OWN_${suffix}`
} }
// TODO Commonize with Node context // TODO Commonize with Node context
@ -527,7 +525,7 @@ class MautrixController {
* @typedef PathImage * @typedef PathImage
* @type object * @type object
* @property {?string} path - The virtual path of the image (behaves like an ID). Optional. * @property {?string} path - The virtual path of the image (behaves like an ID). Optional.
* @property {string} src - The URL of the image. Mandatory. * @property {string} url - The URL of the image. Mandatory.
*/ */
/** /**
@ -683,6 +681,49 @@ class MautrixController {
return promise return promise
} }
/**
* Wait for updates to the active chat's message list to settle down.
* Wait an additional bit of time every time an update is observed.
* TODO Look (harder) for an explicit signal of when a chat is fully updated...
*
* @returns Promise<void>
*/
waitForMessageListStability() {
// Increase this if messages get missed on sync / chat change.
// Decrease it if response times are too slow.
const delayMillis = 2000
let myResolve
const promise = new Promise(resolve => {myResolve = resolve})
let observer
const onTimeout = () => {
console.log("Message list looks stable, continue")
console.debug(`timeoutID = ${timeoutID}`)
observer.disconnect()
myResolve()
}
let timeoutID
const startTimer = () => {
timeoutID = setTimeout(onTimeout, delayMillis)
}
observer = new MutationObserver(changes => {
clearTimeout(timeoutID)
console.log("CHANGE to message list detected! Wait a bit longer...")
console.debug(`timeoutID = ${timeoutID}`)
console.debug(changes)
startTimer()
})
observer.observe(
document.querySelector("#_chat_message_area"),
{childList: true, attributes: true, subtree: true})
startTimer()
return promise
}
/** /**
* @param {[MutationRecord]} mutations - The mutation records that occurred * @param {[MutationRecord]} mutations - The mutation records that occurred
* @private * @private

View File

@ -36,12 +36,13 @@ export default class MessagesPuppeteer {
* @param {string} id * @param {string} id
* @param {?Client} [client] * @param {?Client} [client]
*/ */
constructor(id, client = null) { constructor(id, ownID, client = null) {
let profilePath = path.join(MessagesPuppeteer.profileDir, id) let profilePath = path.join(MessagesPuppeteer.profileDir, id)
if (!profilePath.startsWith("/")) { if (!profilePath.startsWith("/")) {
profilePath = path.join(process.cwd(), profilePath) profilePath = path.join(process.cwd(), profilePath)
} }
this.id = id this.id = id
this.ownID = ownID
this.profilePath = profilePath this.profilePath = profilePath
this.updatedChats = new Set() this.updatedChats = new Set()
this.sentMessageIDs = new Set() this.sentMessageIDs = new Set()
@ -146,6 +147,18 @@ export default class MessagesPuppeteer {
} }
} }
/**
* Set the contents of a text input field to the given text.
* Works by triple-clicking the input field to select all existing text, to replace it on type.
*
* @param {ElementHandle} inputElement - The input element to type into.
* @param {string} text - The text to input.
*/
async _enterText(inputElement, text) {
await inputElement.click({clickCount: 3})
await inputElement.type(text)
}
/** /**
* Wait for the session to be logged in and monitor changes while it's not. * Wait for the session to be logged in and monitor changes while it's not.
*/ */
@ -238,7 +251,7 @@ export default class MessagesPuppeteer {
this.log("Removing observers") this.log("Removing observers")
// TODO __mautrixController is undefined when cancelling, why? // TODO __mautrixController is undefined when cancelling, why?
await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.id) await this.page.evaluate(ownID => window.__mautrixController.setOwnID(ownID), this.ownID)
await this.page.evaluate(() => window.__mautrixController.removeQRChangeObserver()) await this.page.evaluate(() => window.__mautrixController.removeQRChangeObserver())
await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver()) await this.page.evaluate(() => window.__mautrixController.removeQRAppearObserver())
await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver()) await this.page.evaluate(() => window.__mautrixController.removeEmailAppearObserver())
@ -274,15 +287,6 @@ export default class MessagesPuppeteer {
this.loginRunning = false this.loginRunning = false
await this.blankPage.bringToFront() await this.blankPage.bringToFront()
// Don't start observing yet, instead wait for explicit request.
// But at least view the most recent chat.
try {
const mostRecentChatID = await this.page.$eval("#_chat_list_body li",
element => window.__mautrixController.getChatListItemID(element.firstElementChild))
await this._switchChat(mostRecentChatID)
} catch (e) {
this.log("No chats available to focus on")
}
this.log("Login complete") this.log("Login complete")
} }
@ -464,6 +468,41 @@ export default class MessagesPuppeteer {
() => window.__mautrixController.removeMsgListObserver()) () => window.__mautrixController.removeMsgListObserver())
} }
async getOwnProfile() {
return await this.taskQueue.push(() => this._getOwnProfileUnsafe())
}
async _getOwnProfileUnsafe() {
// NOTE Will send a read receipt if a chat was in view!
// Best to use this on startup when no chat is viewed.
let ownProfile
await this._interactWithPage(async () => {
this.log("Opening settings view")
await this.page.click("button.mdGHD01SettingBtn")
await this.page.waitForSelector("#context_menu li#settings", {visible: true}).then(e => e.click())
await this.page.waitForSelector("#settings_contents", {visible: true})
this.log("Getting own profile info")
ownProfile = {
id: this.ownID,
name: await this.page.$eval("#settings_basic_name_input", e => e.innerText),
avatar: {
path: null,
url: await this.page.$eval(".mdCMN09ImgInput", e => {
const imgStr = e.style?.getPropertyValue("background-image")
const matches = imgStr.match(/url\("(blob:.*)"\)/)
return matches?.length == 2 ? matches[1] : null
}),
},
}
const backSelector = "#label_setting button"
await this.page.click(backSelector)
await this.page.waitForSelector(backSelector, {visible: false})
})
return ownProfile
}
_listItemSelector(id) { _listItemSelector(id) {
return `#_chat_list_body div[data-chatid="${id}"]` return `#_chat_list_body div[data-chatid="${id}"]`
} }
@ -520,6 +559,9 @@ export default class MessagesPuppeteer {
await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info") await this.page.waitForSelector("#_chat_detail_area > .mdRGT02Info")
}) })
this.log("Waiting for chat to stabilize")
await this.page.evaluate(() => window.__mautrixController.waitForMessageListStability())
if (hadMsgListObserver) { if (hadMsgListObserver) {
this.log("Restoring msg list observer") this.log("Restoring msg list observer")
await this.page.evaluate( await this.page.evaluate(
@ -595,8 +637,11 @@ export default class MessagesPuppeteer {
const input = await this.page.$("#_chat_room_input") const input = await this.page.$("#_chat_room_input")
await this._interactWithPage(async () => { await this._interactWithPage(async () => {
// Live-typing in the field can have its text mismatch what was requested!!
// Probably because the input element is a div instead of a real text input...ugh!
// Setting its innerText directly works fine though...
await input.click() await input.click()
await input.type(text) await input.evaluate((e, text) => e.innerText = text, text)
await input.press("Enter") await input.press("Enter")
}) })
@ -808,18 +853,8 @@ export default class MessagesPuppeteer {
async _sendEmailCredentials() { async _sendEmailCredentials() {
this.log("Inputting login credentials") this.log("Inputting login credentials")
await this._enterText(await this.page.$("#line_login_email"), this.login_email)
// Triple-click input fields to select all existing text and replace it on type await this._enterText(await this.page.$("#line_login_pwd"), this.login_password)
let input
input = await this.page.$("#line_login_email")
await input.click({clickCount: 3})
await input.type(this.login_email)
input = await this.page.$("#line_login_pwd")
await input.click({clickCount: 3})
await input.type(this.login_password)
await this.page.click("button#login_btn") await this.page.click("button#login_btn")
} }