Spaces:
Paused
Paused
File size: 14,782 Bytes
2e79f82 109b8ca 2e79f82 65651b1 2e79f82 65651b1 2e79f82 c53c2ab 2e79f82 65651b1 2e79f82 65651b1 2e79f82 65651b1 2e79f82 65651b1 2e79f82 65651b1 2e79f82 65651b1 2e79f82 65651b1 2e79f82 65651b1 2e79f82 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 | """The background side of the sync.
* a timer: every SYNC_INTERVAL_MINUTES each connected channel is compared with My posts (app/sync_engine.py);
* a queue for what Telegram tells the bot while it runs: a post deleted / edited / added in a channel is looked at a few
seconds later (the wait lets a burst of changes settle, and lets the bot finish saving its own posts first);
* service messages ("pinned a message", "channel photo changed" ...) are deleted the moment they appear.
Nothing here holds the bot's one-long-job lock: a running /repost, /shift, /swap ... always wins, and what happens
while it runs is picked up by the next timed check.
"""
from __future__ import annotations
import asyncio
import logging
import time
from typing import Optional
from .servicemsg import RIGHTS_ERRORS, action_label, remove
from .sync_engine import SyncOptions, SyncReport, sync_channel, sync_ids
from .tgutil import esc, peer_of
log = logging.getLogger(__name__)
class Syncer:
def __init__(self, ctx):
self.ctx = ctx
# timings (seconds) - the tests make them small
self.start_delay = 90.0 # after the bot starts, before the first timed check
self.debounce = 5.0 # quiet time needed before the queue is worked off
self.max_debounce = 30.0 # ... but never wait longer than this
self.lock_wait = 30.0 # a long job runs: look again after this
self.rt_grace = 10.0 # live changes: posts the bot touched / messages younger than this are judged next round
self.pause = 0.25 # between two requests
self.cache_ttl = 60.0 # how long "is this channel connected?" is remembered
self.tell_every = 86400.0 # the same warning goes to the owners at most this often
self.tell_restricted_every = 7 * 86400.0 # ... a restriction by Telegram can last for weeks: once a week
self.max_ids = 300 # more changed ids than this in one channel: look at the whole channel instead
self.max_tries = 8 # a message that stays "too fresh" is given up after this many rounds
self.history_seconds = 150.0 # a timed check reads a channel's older posts for this long, then goes on next time
self._busy = asyncio.Lock() # one look at a time
self._edits: dict = {} # channel id -> message ids edited
self._news: dict = {} # channel id -> message ids that are new posts
self._tries: dict = {}
self._stamp = 0
self._wake = asyncio.Event()
self._tasks: list = []
self._cache: dict = {}
self._told: dict = {}
self.last_reports: dict = {} # channel id -> its latest SyncReport
# ----------------------------------------------------------------------------------------- running
@property
def enabled(self) -> bool:
return self.ctx.cfg.sync_interval_minutes > 0
def start(self) -> None:
if self._tasks or not self.enabled:
return
self._tasks = [
asyncio.create_task(self._periodic(), name="sync-timer"),
asyncio.create_task(self._events(), name="sync-live"),
]
async def stop(self) -> None:
tasks, self._tasks = self._tasks, []
for t in tasks:
t.cancel()
for t in tasks:
try:
await t
except (asyncio.CancelledError, Exception):
pass
async def _periodic(self) -> None:
await asyncio.sleep(self.start_delay)
while True:
try:
await self.run_all()
except asyncio.CancelledError:
raise
except Exception:
log.exception("the timed check of the channels failed")
await asyncio.sleep(max(5, self.ctx.cfg.sync_interval_minutes) * 60)
async def _events(self) -> None:
while True:
await self._wake.wait()
self._wake.clear()
try:
await self.flush_events()
except asyncio.CancelledError:
raise
except Exception:
log.exception("working off the channel changes failed")
# ----------------------------------------------------------------------------------------- helpers
async def channel(self, cid: int):
"""The connected channel with this id, or None (remembered for a minute: busy channels cost no database reads)."""
now = time.monotonic()
hit = self._cache.get(cid)
if hit is not None and now - hit[0] < self.cache_ttl:
return hit[1]
ch = await self.ctx.db.get_channel(cid)
if ch is not None and not ch.active:
ch = None
self._cache[cid] = (now, ch)
return ch
def forget_cache(self) -> None:
self._cache.clear()
def exclusive(self):
"""Held (`async with`) while no look at any channel runs - for a change that must not race with one."""
return self._busy
def options(self, **kw) -> SyncOptions:
ctx = self.ctx
base = dict(
delete_services=ctx.cfg.delete_service_messages,
history=ctx.cfg.import_old_posts,
ignore=ctx.sync_ignore,
pause=self.pause,
)
base.update(kw)
return SyncOptions(**base)
async def tell_owners(self, ch, key: str, text: str, every: Optional[float] = None) -> None:
"""A warning for the owners - the same one (per channel and kind) at most once a day (or every `every` seconds)."""
now = time.monotonic()
k = (ch.id, key)
if now - self._told.get(k, -1e12) < (self.tell_every if every is None else every):
return
self._told[k] = now
for uid in sorted(self.ctx.cfg.owners):
try:
await self.ctx.client.send_message(uid, text, link_preview=False)
except Exception:
log.warning("could not warn user %s about %s", uid, ch.id, exc_info=True)
async def _after(self, ch, rep: SyncReport) -> None:
self.last_reports[ch.id] = rep
if rep.changed or rep.error or rep.anomaly:
log.info(
"channel %s: %s removed, %s updated, %s new, %s service messages deleted%s",
ch.id, len(rep.deleted), len(rep.edited), len(rep.adopted), rep.services_deleted,
f" - {rep.error or rep.anomaly}" if (rep.error or rep.anomaly) else "",
)
if rep.services_failed and rep.services_error in RIGHTS_ERRORS:
await self.tell_owners(ch, "rights", self._rights_text(ch, rep.services_error))
if rep.restricted_channel:
log.info("channel %s is restricted by Telegram (%s): My posts left untouched", ch.id, rep.restricted_channel)
text = self._restricted_text(ch, rep.restricted_channel)
await self.tell_owners(ch, "restricted", text, every=self.tell_restricted_every)
elif rep.restricted:
log.info("channel %s: %s post(s) held back by Telegram (%s): left untouched", ch.id, len(rep.restricted), rep.restricted_why)
text = self._held_back_text(ch, len(rep.restricted), rep.restricted_why)
await self.tell_owners(ch, "held-back", text, every=self.tell_restricted_every)
@staticmethod
def _restricted_text(ch, why: str) -> str:
return (
f"🚫 Telegram has restricted <b>{esc(ch.title)}</b> ({esc(why)}). While that lasts I don't change anything in "
"My posts for this channel - the saved posts stay exactly as they are, whatever the channel shows now."
)
@staticmethod
def _held_back_text(ch, n: int, why: Optional[str]) -> str:
return (
f"🚫 Telegram holds back {n} post(s) of <b>{esc(ch.title)}</b> ({esc(why or 'restricted')}). "
"Their saved copies in My posts stay exactly as they are."
)
@staticmethod
def _rights_text(ch, why: Optional[str]) -> str:
return (
f"⚠️ I can't delete the service messages (name changes, “pinned a message” ...) in <b>{esc(ch.title)}</b> "
f"({esc(why or 'refused')}). Give the bot the <i>Delete messages</i> right there, or set "
"DELETE_SERVICE_MESSAGES=false to stop trying."
)
# ------------------------------------------------------------------------------------ full looks
async def run_all(
self, chans: Optional[list] = None, *, explicit: bool = False, history: Optional[bool] = None, progress=None
) -> list:
"""Look at the connected channels one after the other. `explicit` (the /sync command): the caller holds the
one-long-job lock and everything is read; the timed check gives way to any long job instead. `history`: True
reads the channels' older posts even if IMPORT_OLD_POSTS is off (/sync --history)."""
ctx = self.ctx
if chans is None:
chans = await ctx.db.list_channels()
busy = (lambda: False) if explicit else ctx.lock.locked
reports: list = []
for ch in chans:
if busy():
break
async def prog(rep, ch=ch):
if progress is not None:
await progress(ch, rep)
try:
async with self._busy:
extra = {} if history is None else {"history": history}
opts = self.options(deep=explicit, history_seconds=None if explicit else self.history_seconds, **extra)
rep = await sync_channel(ctx.client, ctx.db, ch, opts, busy=busy, progress=prog)
except asyncio.CancelledError:
raise
except Exception as e:
log.exception("looking at channel %s failed", ch.id)
rep = SyncReport(channel=ch, error=f"{type(e).__name__}: {str(e)[:120]}")
await self._after(ch, rep)
reports.append(rep)
return reports
# ----------------------------------------------------------------------------- live: what updates say
def _touch(self) -> None:
self._stamp += 1
self._wake.set()
def note_new(self, cid: int, msg) -> None:
"""A post appeared in a channel. The bot's own posts are skipped (it saves them itself)."""
if not self.enabled or self.ctx.lock.locked() or getattr(msg, "out", False):
return
self._news.setdefault(cid, set()).add(msg.id)
self._touch()
def note_edit(self, cid: int, msg) -> None:
if not self.enabled or self.ctx.lock.locked():
return
self._edits.setdefault(cid, set()).add(msg.id)
self._touch()
async def note_deleted(self, cid: int, ids) -> None:
"""Posts were deleted in a channel: they leave My posts at once (nothing to read from Telegram for that)."""
if not self.enabled:
return
ch = await self.channel(cid)
if ch is None:
return
for pending in (self._edits, self._news):
if cid in pending:
pending[cid].difference_update(ids)
n = await self.ctx.db.forget_posts(cid, list(ids))
if n:
log.info("%s post(s) were deleted in channel %s: removed from My posts", n, cid)
async def on_service(self, cid: int, msg) -> None:
"""A service message appeared: delete it."""
if not self.ctx.cfg.delete_service_messages:
return
ch = await self.channel(cid)
if ch is None:
return
out = await remove(self.ctx.client, peer_of(ch), [msg.id])
if out.error is None:
log.info("removed the “%s” notice %s from channel %s", action_label(msg), msg.id, cid)
return
log.warning("could not delete the “%s” notice %s in channel %s: %s", action_label(msg), msg.id, cid, out.error_name)
if out.error_name in RIGHTS_ERRORS:
await self.tell_owners(ch, "rights", self._rights_text(ch, out.error_name))
def _pending(self) -> bool:
return bool(self._edits or self._news)
async def flush_events(self) -> None:
"""Work off the queue once it has been quiet for a moment (and no long job is running)."""
first = time.monotonic()
while self._pending():
stamp = self._stamp
await asyncio.sleep(self.debounce)
if stamp != self._stamp and time.monotonic() - first < self.max_debounce:
continue # more is still coming in: let it settle
if self.ctx.lock.locked():
await asyncio.sleep(self.lock_wait)
continue
edits, news = self._edits, self._news
self._edits, self._news = {}, {}
await self._process(edits, news)
first = time.monotonic()
def _requeue(self, pending: dict, cid: int, mid: int) -> None:
key = (cid, mid)
self._tries[key] = self._tries.get(key, 0) + 1
if self._tries[key] > self.max_tries:
self._tries.pop(key, None)
return
pending.setdefault(cid, set()).add(mid)
async def _process(self, edits: dict, news: dict) -> None:
ctx = self.ctx
for cid in sorted(set(edits) | set(news)):
ch = await self.channel(cid)
if ch is None:
continue
e_ids, n_ids = set(edits.get(cid, ())), set(news.get(cid, ()))
opts = self.options(grace=self.rt_grace, history=False) # live: the older posts wait for the timed check
try:
async with self._busy:
if len(e_ids | n_ids) > self.max_ids: # a burst: one look at the whole channel is cheaper
rep = await sync_channel(ctx.client, ctx.db, ch, opts, busy=ctx.lock.locked)
else:
rep = await sync_ids(
ctx.client, ctx.db, ch, e_ids | n_ids, opts, adopt_ids=n_ids, busy=ctx.lock.locked
)
except asyncio.CancelledError:
raise
except Exception as e:
log.exception("looking at the changes in channel %s failed", cid)
rep = SyncReport(channel=ch, error=f"{type(e).__name__}: {str(e)[:120]}")
await self._after(ch, rep)
if rep.busy: # a long job started in the meantime: let the next timed check have it
continue
for mid in set(rep.young):
self._requeue(self._edits, cid, mid)
for mid in set(rep.young_new):
self._requeue(self._news, cid, mid)
for mid in (e_ids | n_ids) - set(rep.young) - set(rep.young_new):
self._tries.pop((cid, mid), None)
|