4
0
mirror of https://github.com/jesopo/ircrobots synced 2024-06-19 06:17:05 +00:00

Compare commits

...

106 Commits

Author SHA1 Message Date
jesopo
7c9a144124 v0.6.6 release 2023-08-17 22:48:21 +00:00
jesopo
e3c91a50e1 update ircstates 2023-08-17 22:47:57 +00:00
jesopo
f2ba48a582 v0.6.5 release 2023-07-06 00:57:08 +00:00
jesopo
cf2e69a9e2 asyncio.wait(..) now requires Tasks 2023-07-06 00:56:45 +00:00
jesopo
a1a459c13e v0.6.4 release 2023-07-06 00:44:25 +00:00
jesopo
81fa77cf29 missed some TLS_ uses 2023-07-06 00:44:13 +00:00
jesopo
422a9a93c1 v0.6.3 release 2023-07-06 00:35:47 +00:00
jesopo
b04a0e0136 python no longer likes having mutables in non-default_factory 2023-07-06 00:35:13 +00:00
jesopo
7bb4c3d069 v0.6.2 release 2023-02-06 19:43:14 +00:00
jesopo
9a2f2156fe support specifying tls client keypair 2023-02-06 19:42:27 +00:00
alicetries
0435404ec3 Small tweak to how repr() of Formatless() displays 2022-03-28 23:43:27 +01:00
jesopo
63025af311 v0.6.1 release 2022-02-19 13:51:05 +00:00
jesopo
20c4f8f98c upgrade async-timeout to v4.0.2 2022-02-19 13:49:21 +00:00
jesopo
0ce3b9b0b0 v0.6.0 release 2022-01-24 10:01:51 +00:00
jesopo
5b347f95c9 combine params.tls and .tls_verify, support pinned certs 2022-01-24 09:53:32 +00:00
jesopo
0a5c774965 v0.5.0 release 2022-01-20 21:28:23 +00:00
jesopo
8245a411c0 hmm no this isnt how you ask for cert validation apparently 2022-01-20 21:24:54 +00:00
jesopo
80b941fa53 handle ERR_UNAVAILRESOURCE for prereg NICK failure too 2022-01-16 15:24:22 +00:00
jesopo
b7019d35c1 upgrade ircstates 2022-01-07 19:04:48 +00:00
jesopo
66358f77e3 v0.4.7 release 2022-01-07 11:45:32 +00:00
jesopo
fcd2f5b1b2 upgrade ircstates 2022-01-07 11:43:30 +00:00
jesopo
3e18deef86 we don't support py3.6; support py3.9 2022-01-07 11:41:35 +00:00
jesopo
9ba5b2b90f add transport (ITCPTransport) param to bot.add_server 2021-12-18 16:48:01 +00:00
jesopo
025fde97ee v0.4.6 release 2021-12-09 23:53:14 +00:00
jesopo
05750f00d9 make sure 'tls' is defined 2021-12-09 23:49:40 +00:00
jesopo
ac4c144d58 v0.4.5 release 2021-11-29 16:11:54 +00:00
jesopo
6c91ebc7ec add ConnectionParams.from_hoststring("nick", "host:+port") 2021-11-29 16:09:26 +00:00
jesopo
0edcbfa234 v0.4.4 release 2021-09-19 21:36:20 +00:00
jesopo
7b6a845927 don't infinitely loop SASLUserPass attempts on FAIL or ABORT 2021-09-19 21:34:57 +00:00
jesopo
dfd78b3d3e v0.4.3 release 2021-09-19 21:32:02 +00:00
jesopo
ab65e39ab9 handle ERR_SASLABORTED 2021-09-18 17:34:52 +00:00
jesopo
9ca1ec21c9 v0.4.2 release 2021-09-18 17:15:53 +00:00
jesopo
a03f11449c upgrade ircstates to v0.11.10 2021-09-18 17:11:40 +00:00
jesopo
bb87c86b37 v0.4.1 release 2021-09-11 15:44:15 +00:00
jesopo
8ee692f1be upgrade ircstates to 0.11.9 2021-09-11 15:43:40 +00:00
jesopo
c7604686a2 channel_user.modes is now a set 2021-09-11 15:42:47 +00:00
jesopo
64935c7a8d react to pre-reg ERR_ERRONEUSNICKNAME the same as ERR_NICKNAMEINUSE 2021-09-11 15:40:18 +00:00
jesopo
fb93d59c43 v0.4.0 release 2021-06-26 15:11:32 +00:00
jesopo
ab17645d83 catch reconnection failures, do exponential backoff 2021-06-26 15:08:48 +00:00
jesopo
8d3681eba1 freenode is dead long live libera.chat 2021-05-24 18:08:26 +00:00
jesopo
930342d74f v0.3.14 release 2021-05-22 08:43:50 +00:00
jesopo
dd41b0dbde parse tokens in wait_for - waity things expect state change 2021-05-22 08:43:11 +00:00
jesopo
f22471993a v0.3.13 release 2021-05-12 12:35:39 +00:00
jesopo
6fddfb7fe9 reset ping_sent in wait_for too 2021-05-12 12:34:06 +00:00
jesopo
b4eaf6c24c v0.3.12 release 2021-05-12 11:56:24 +00:00
jesopo
bdfb91b51d invert ping check 2021-05-12 11:52:33 +00:00
jesopo
a14c7c34a2 v0.3.11 release 2021-05-12 11:28:27 +00:00
jesopo
3574868458 reset ping timer when we read a line 2021-05-12 11:24:54 +00:00
jesopo
0253aba99e v0.3.10 release 2021-05-12 10:58:51 +00:00
jesopo
bfb5b4ec61 v0.3.9 release 2021-05-12 10:54:47 +00:00
jesopo
6a05370a12 simplify wait_for 2021-05-12 10:52:39 +00:00
jesopo
90fb4b7bba v0.3.8 release 2021-04-10 13:55:04 +00:00
jesopo
d0c6b4a43d update ircstates to 0.11.8 2021-04-10 13:54:00 +00:00
jesopo
fc0e8470cc change pre-001 throttle to 100 lines in 1 second 2021-03-26 12:35:02 +00:00
jesopo
d0e0314169 _check_regain wants a string list, not a string 2020-12-20 00:42:18 +00:00
jesopo
a15e2bd1fb "001" literal -> RPL_WELCOME 2020-12-20 00:40:31 +00:00
jesopo
7a59ece687 try to regain nick on servers that have WATCH or MONITOR 2020-12-20 00:40:11 +00:00
jesopo
e7779bcf17 update ircstates to v0.11.7 2020-12-20 00:39:26 +00:00
jesopo
04b44e2e94 v0.3.7 release 2020-12-02 17:26:18 +00:00
jesopo
69e303dfa9 update anyio from 1.3.0 to 2.0.2 2020-12-02 17:10:04 +00:00
jesopo
def58730bc v0.3.6 release 2020-12-02 10:42:05 +00:00
jesopo
4f5fd90ca5 update ircstates to ~=0.11.6 2020-12-01 21:48:40 +00:00
jesopo
efc280b2e9 don't pin specific versions (~= instead) in requirements.txt
(and fix dataclasses for 3.7)
2020-12-01 15:59:02 +00:00
jesopo
834ca4b817 v0.3.5 release 2020-11-09 03:46:06 +00:00
jesopo
48b0748b92 upgrade ircstates to v0.11.5 2020-11-09 03:44:39 +00:00
jesopo
bd4758e97c v0.3.4 release 2020-10-12 18:03:39 +00:00
jesopo
805b247375 successful pre-reg NICK has no ack, so we can't await a result 2020-10-10 22:56:45 +00:00
jesopo
acd9211225 v0.3.3 release 2020-09-30 11:11:39 +00:00
jesopo
70476c9fc9 _pending_who.pop() -> _pending_who.popleft(), or we WHO the same chan 2020-09-30 11:11:06 +00:00
jesopo
8aaad83dbe v0.3.2 release 2020-09-30 11:04:16 +00:00
jesopo
8495838541 sent_ping wasn't be used for some reason 2020-09-30 11:03:17 +00:00
jesopo
bfdae87b36 handle ERR_NICKNAMEINUSE pre-registration (add alt-nicknames param) 2020-09-30 10:06:47 +00:00
jesopo
4d9dcf0652 v0.3.1 release 2020-09-30 09:28:44 +00:00
jesopo
f1d9e33bae upgrade ircstates to v0.11.2 2020-09-30 09:24:38 +00:00
jesopo
99254abc9c also query MODE +beIq on JOIN, don't hold the thread for ENDOFWHO 2020-09-29 12:14:15 +00:00
jesopo
cdad0895e1 add very basic autojoin support 2020-09-25 18:03:02 +00:00
jesopo
ce2338e4db v0.3.0 release 2020-09-24 19:44:41 +00:00
jesopo
a264e4e347 scrap deferred wait_for, actually catch server disconnection 2020-09-24 19:43:03 +00:00
jesopo
eb9888d0c4 v0.2.14 release 2020-08-09 21:06:39 +00:00
jesopo
6d2cf3f465 rename wait_for()'s timeout param, actually use it 2020-08-09 21:04:49 +00:00
jesopo
8bb95949ed v0.2.13 release 2020-08-07 18:20:58 +00:00
jesopo
fcce50fb4e ircstates update was missed 2020-08-07 15:22:05 +00:00
jesopo
28faec42fa update ircstates to v0.11.1, manually parse_tokens() 2020-08-07 15:00:02 +00:00
jesopo
77ae261bec v0.2.12 release 2020-07-13 11:37:54 +01:00
jesopo
ea991d098c update ircstate to v0.10.3 2020-07-13 11:35:03 +01:00
jesopo
294e69527e update README.md contact section to point to freenode 2020-07-10 12:11:20 +01:00
jesopo
9d1f299800 v0.2.11 release 2020-07-01 18:03:49 +01:00
jesopo
57f742ec0a fix glob collapse tests 2020-07-01 18:02:16 +01:00
jesopo
7f9e6c99fb update ircstates to v0.10.1 2020-07-01 17:59:57 +01:00
jesopo
b3a667fc71 ChannelUser objects take Names now 2020-07-01 17:52:44 +01:00
jesopo
4152f4d281 _collapse(s) was moved to collapse(s) 2020-07-01 17:52:29 +01:00
jesopo
7e575d733e fix ircrobots.matching not being packaged 2020-07-01 17:45:32 +01:00
jesopo
7c1d2568d0 update ircstates to v0.10.0 2020-07-01 17:45:12 +01:00
jesopo
839debff93 glob._collapse -> glob.collapse 2020-06-29 10:30:59 +01:00
jesopo
d1b49fb89a v0.2.10 release 2020-06-23 22:24:20 +01:00
jesopo
0fc66f7f7b add a timeout (wtimeout) param to wait_for 2020-06-23 22:23:52 +01:00
jesopo
331b497c8a v0.2.9 release 2020-06-23 10:37:59 +01:00
jesopo
a08f53b7f7 ping timeout giveup shouldn't hit continue too 2020-06-23 10:36:06 +01:00
jesopo
a102230495 python3.7 asyncio.Task has no .get_name() 2020-06-21 16:53:05 +01:00
jesopo
883f09e31c switch _next_lines and _read_lines to generators. taskgroup wait_fors! 2020-06-21 16:47:53 +01:00
jesopo
75c12d83e8 put a 20 second timeout on wait_for calls 2020-06-15 11:13:52 +01:00
jesopo
11873094aa update ircstates to v0.9.19 2020-06-14 19:55:12 +01:00
jesopo
d85f359293 SHA-1 scram name should be SHA1 (and fix typo in error rethrow) 2020-06-14 18:37:11 +01:00
jesopo
67e6064b67 move serialised who mechanism in to Server. dont rely on exclusive wait_for 2020-06-14 18:36:37 +01:00
jesopo
6e25b6c51d v0.2.8 release 2020-06-13 00:31:18 +01:00
jesopo
383d3acc8d don't leave old _wait_for_futs lying around 2020-06-13 00:28:27 +01:00
23 changed files with 333 additions and 185 deletions

View File

@ -3,7 +3,7 @@ cache: pip
python:
- "3.7"
- "3.8"
- "3.8-dev"
- "3.9"
install:
- pip3 install mypy -r requirements.txt
script:

View File

@ -8,3 +8,7 @@ to add/remove servers.
## usage
see [examples/](examples/) for some usage demonstration.
## contact
Come say hi at `#irctokens` on irc.libera.chat

View File

@ -1 +1 @@
0.2.7
0.6.6

View File

@ -154,8 +154,8 @@ async def main(hostname: str, channel: str, nickname: str):
params = ConnectionParams(
nickname,
hostname,
6697,
tls=True)
6697
)
await bot.add_server("freenode", params)
await bot.run()

View File

@ -23,7 +23,6 @@ async def main():
"MyNickname",
host = "chat.freenode.invalid",
port = 6697,
tls = True,
sasl = sasl_params)
await bot.add_server("freenode", params)

View File

@ -25,7 +25,7 @@ class Bot(BaseBot):
async def main():
bot = Bot()
for name, host in SERVERS:
params = ConnectionParams("BitBotNewTest", host, 6697, True)
params = ConnectionParams("BitBotNewTest", host, 6697)
await bot.add_server(name, params)
await bot.run()

View File

@ -3,3 +3,4 @@ from .server import Server
from .params import (ConnectionParams, SASLUserPass, SASLExternal, SASLSCRAM,
STSPolicy, ResumePolicy)
from .ircv3 import Capability
from .security import TLS

View File

@ -1,7 +1,8 @@
from asyncio import Future
from irctokens import Line
from typing import (Any, Awaitable, Callable, Generator, Generic, Optional,
TypeVar)
from irctokens import Line
from .matching import IMatchResponse
from .interface import IServer
from .ircv3 import TAG_LABEL
@ -17,8 +18,10 @@ class MaybeAwait(Generic[TEvent]):
class WaitFor(object):
def __init__(self,
response: IMatchResponse):
response: IMatchResponse,
deadline: float):
self.response = response
self.deadline = deadline
self._label: Optional[str] = None
self._our_fut: "Future[Line]" = Future()

View File

@ -1,4 +1,4 @@
import asyncio
import asyncio, traceback
import anyio
from typing import Dict
@ -6,42 +6,55 @@ from ircstates.server import ServerDisconnectedException
from .server import ConnectionParams, Server
from .transport import TCPTransport
from .interface import IBot, IServer
from .interface import IBot, IServer, ITCPTransport
class Bot(IBot):
def __init__(self):
self.servers: Dict[str, Server] = {}
self._server_queue: asyncio.Queue[Server] = asyncio.Queue()
# methods designed to be overridden
def create_server(self, name: str):
return Server(self, name)
async def disconnected(self, server: IServer):
if (server.name in self.servers and
server.params is not None and
server.disconnected):
await asyncio.sleep(server.params.reconnect)
reconnect = server.params.reconnect
while True:
await asyncio.sleep(reconnect)
try:
await self.add_server(server.name, server.params)
# /methods designed to be overridden
except Exception as e:
traceback.print_exc()
# let's try again, exponential backoff up to 5 mins
reconnect = min(reconnect*2, 300)
else:
break
async def disconnect(self, server: IServer):
await server.disconnect()
del self.servers[server.name]
await server.disconnect()
async def add_server(self, name: str, params: ConnectionParams) -> Server:
async def add_server(self,
name: str,
params: ConnectionParams,
transport: ITCPTransport = TCPTransport()) -> Server:
server = self.create_server(name)
self.servers[name] = server
await server.connect(TCPTransport(), params)
await server.connect(transport, params)
await self._server_queue.put(server)
return server
async def _run_server(self, server: Server):
try:
async with anyio.create_task_group() as tg:
async def _read():
while True:
await server._read_lines()
await tg.spawn(_read)
await tg.spawn(server._read_lines)
await tg.spawn(server._send_lines)
except ServerDisconnectedException:
server.disconnected = True
await self.disconnected(server)

View File

@ -1,5 +1,5 @@
def _collapse(pattern: str) -> str:
def collapse(pattern: str) -> str:
out = ""
i = 0
while i < len(pattern):
@ -51,4 +51,4 @@ class Glob(object):
def match(self, s: str) -> bool:
return _match(self._pattern, s)
def compile(pattern: str) -> Glob:
return Glob(_collapse(pattern))
return Glob(collapse(pattern))

View File

@ -6,6 +6,7 @@ from ircstates import Server, Emit
from irctokens import Line, Hostmask
from .params import ConnectionParams, SASLParams, STSPolicy, ResumePolicy
from .security import TLS
class ITCPReader(object):
async def read(self, byte_count: int):
@ -26,8 +27,7 @@ class ITCPTransport(object):
async def connect(self,
hostname: str,
port: int,
tls: bool,
tls_verify: bool=True,
tls: Optional[TLS],
bindhost: Optional[str]=None
) -> Tuple[ITCPReader, ITCPWriter]:
pass

View File

@ -8,6 +8,7 @@ from .contexts import ServerContext
from .matching import Response, ANY
from .interface import ICapability
from .params import ConnectionParams, STSPolicy, ResumePolicy
from .security import TLSVerifyChain
class Capability(ICapability):
def __init__(self,
@ -101,12 +102,12 @@ def _cap_dict(s: str) -> Dict[str, str]:
return d
async def sts_transmute(params: ConnectionParams):
if not params.sts is None and not params.tls:
if not params.sts is None and params.tls is None:
now = time()
since = (now-params.sts.created)
if since <= params.sts.duration:
params.port = params.sts.port
params.tls = True
params.tls = TLSVerifyChain()
async def resume_transmute(params: ConnectionParams):
if params.resume is not None:
params.host = params.resume.address
@ -182,7 +183,7 @@ class CAPContext(ServerContext):
if not params.tls:
if "port" in sts_dict:
params.port = int(sts_dict["port"])
params.tls = True
params.tls = TLSVerifyChain()
await self.server.bot.disconnect(self.server)
await self.server.bot.add_server(self.server.name, params)

View File

@ -1,17 +0,0 @@
from typing import Dict, Iterable, List, Optional
from irctokens import build
from ircstates.numerics import *
from .contexts import ServerContext
from .matching import Response, ANY, Folded
class WHOContext(ServerContext):
async def ensure(self, channel: str):
if self.server.isupport.whox:
await self.server.send(self.server.prepare_whox(channel))
else:
await self.server.send(build("WHO", [channel]))
line = await self.server.wait_for(
Response(RPL_ENDOFWHO, [ANY, Folded(channel)])
)

View File

@ -73,8 +73,7 @@ class Formatless(IMatchResponseParam):
def __init__(self, value: TYPE_MAYBELIT_VALUE):
self._value = _assure_lit(value)
def __repr__(self) -> str:
brepr = super().__repr__()
return f"Formatless({brepr})"
return f"Formatless({self._value!r})"
def match(self, server: IServer, arg: str) -> bool:
strip = formatting.strip(arg)
return self._value.match(server, strip)

View File

@ -1,5 +1,8 @@
from typing import Optional
from dataclasses import dataclass
from re import compile as re_compile
from typing import List, Optional
from dataclasses import dataclass, field
from .security import TLS, TLSNoVerify, TLSVerifyChain
class SASLParams(object):
mechanism: str
@ -28,22 +31,53 @@ class ResumePolicy(object):
address: str
token: str
RE_IPV6HOST = re_compile("\[([a-fA-F0-9:]+)\]")
_TLS_TYPES = {
"+": TLSVerifyChain,
"~": TLSNoVerify,
}
@dataclass
class ConnectionParams(object):
nickname: str
host: str
port: int
tls: bool
tls: Optional[TLS] = field(default_factory=TLSVerifyChain)
username: Optional[str] = None
realname: Optional[str] = None
bindhost: Optional[str] = None
password: Optional[str] = None
tls_verify: bool = True
sasl: Optional[SASLParams] = None
sts: Optional[STSPolicy] = None
resume: Optional[ResumePolicy] = None
reconnect: int = 10 # seconds
alt_nicknames: List[str] = field(default_factory=list)
autojoin: List[str] = field(default_factory=list)
@staticmethod
def from_hoststring(
nickname: str,
hoststring: str
) -> "ConnectionParams":
ipv6host = RE_IPV6HOST.search(hoststring)
if ipv6host is not None and ipv6host.start() == 0:
host = ipv6host.group(1)
port_s = hoststring[ipv6host.end()+1:]
else:
host, _, port_s = hoststring.strip().partition(":")
tls_type: Optional[TLS] = None
if not port_s:
port_s = "6667"
else:
tls_type = _TLS_TYPES.get(port_s[0], lambda: None)()
if tls_type is not None:
port_s = port_s[1:] or "6697"
return ConnectionParams(nickname, host, int(port_s), tls_type)

View File

@ -32,7 +32,9 @@ AUTH_BYTE_MAX = 400
AUTHENTICATE_ANY = Response("AUTHENTICATE", [ANY])
NUMERICS_FAIL = Response(ERR_SASLFAIL)
NUMERICS_INITIAL = Responses([ERR_SASLFAIL, ERR_SASLALREADY, RPL_SASLMECHS])
NUMERICS_INITIAL = Responses([
ERR_SASLFAIL, ERR_SASLALREADY, RPL_SASLMECHS, ERR_SASLABORTED
])
NUMERICS_LAST = Responses([RPL_SASLSUCCESS, ERR_SASLFAIL])
def _b64e(s: str):
@ -150,6 +152,8 @@ class SASLContext(ServerContext):
return SASLResult.SUCCESS
elif line.command == "904":
match.pop(0)
else:
break
return SASLResult.FAILURE
@ -161,7 +165,7 @@ class SASLContext(ServerContext):
try:
algo = SCRAMAlgorithm(algo_str_prep)
except ValueError:
raise ValueError("Unknown SCRAM algorithm '%s'" % algo)
raise ValueError("Unknown SCRAM algorithm '%s'" % algo_str_prep)
scram = SCRAMContext(algo, username, password)
client_first = _b64eb(scram.client_first())

View File

@ -8,7 +8,7 @@ from typing import Dict
# MD2 has been removed as it's unacceptably weak
class SCRAMAlgorithm(Enum):
MD5 = "MD5"
SHA_1 = "SHA-1"
SHA_1 = "SHA1"
SHA_224 = "SHA224"
SHA_256 = "SHA256"
SHA_384 = "SHA384"

View File

@ -1,13 +1,29 @@
import ssl
from dataclasses import dataclass
from typing import Optional, Tuple
@dataclass
class TLS:
client_keypair: Optional[Tuple[str, str]] = None
# tls without verification
class TLSNoVerify(TLS):
pass
# verify via CAs
class TLSVerifyChain(TLS):
pass
# verify by a pinned hash
class TLSVerifyHash(TLSNoVerify):
def __init__(self, sum: str):
self.sum = sum.lower()
class TLSVerifySHA512(TLSVerifyHash):
pass
def tls_context(verify: bool=True) -> ssl.SSLContext:
context = ssl.SSLContext(ssl.PROTOCOL_TLS)
context.options |= ssl.OP_NO_SSLv2
context.options |= ssl.OP_NO_SSLv3
context.options |= ssl.OP_NO_TLSv1
context.load_default_certs()
if verify:
context.verify_mode = ssl.CERT_REQUIRED
return context
ctx = ssl.create_default_context()
if not verify:
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
return ctx

View File

@ -1,21 +1,23 @@
import asyncio
from asyncio import Future, PriorityQueue
from typing import (Awaitable, Deque, Dict, List, Optional, Set, Tuple,
Union)
from typing import (AsyncIterable, Awaitable, Deque, Dict, Iterable, List,
Optional, Set, Tuple, Union)
from collections import deque
from time import monotonic
import anyio
from asyncio_rlock import RLock
from asyncio_throttle import Throttler
from async_timeout import timeout
from async_timeout import timeout as timeout_
from ircstates import Emit, Channel, ChannelUser
from ircstates.numerics import *
from ircstates.server import ServerDisconnectedException
from ircstates.names import Name
from irctokens import build, Line, tokenise
from .ircv3 import (CAPContext, sts_transmute, CAP_ECHO, CAP_SASL,
CAP_LABEL, LABEL_TAG_MAP, resume_transmute)
from .sasl import SASLContext, SASLResult
from .join_info import WHOContext
from .matching import (ResponseOr, Responses, Response, ANY, SELF, MASK_SELF,
Folded)
from .asyncs import MaybeAwait, WaitFor
@ -28,6 +30,7 @@ from .interface import ITCPTransport, ITCPReader, ITCPWriter
THROTTLE_RATE = 4 # lines
THROTTLE_TIME = 2 # seconds
PING_TIMEOUT = 60 # seconds
WAIT_TIMEOUT = 20 # seconds
JOIN_ERR_FIRST = [
ERR_NOSUCHCHANNEL,
@ -52,20 +55,26 @@ class Server(IServer):
self.disconnected = False
self.throttle = Throttler(
rate_limit=100, period=THROTTLE_TIME)
self.throttle = Throttler(rate_limit=100, period=1)
self.sasl_state = SASLResult.NONE
self.last_read = -1.0
self.last_read = monotonic()
self._sent_count: int = 0
self._send_queue: PriorityQueue[SentLine] = PriorityQueue()
self.desired_caps: Set[ICapability] = set([])
self._read_queue: Deque[Tuple[Line, Optional[Emit]]] = deque()
self._read_queue: Deque[Line] = deque()
self._process_queue: Deque[Tuple[Line, Optional[Emit]]] = deque()
self._wait_fors: List[Tuple[WaitFor, Optional[Awaitable]]] = []
self._wait_for_fut: Optional[Future[WaitFor]] = None
self._ping_sent = False
self._read_lguard = RLock()
self.read_lock = self._read_lguard
self._read_lwork = asyncio.Lock()
self._wait_for = asyncio.Event()
self._pending_who: Deque[str] = deque()
self._alt_nicks: List[str] = []
def hostmask(self) -> str:
hostmask = self.nickname
@ -116,7 +125,6 @@ class Server(IServer):
params.host,
params.port,
tls =params.tls,
tls_verify=params.tls_verify,
bindhost =params.bindhost)
self._reader = reader
@ -135,6 +143,11 @@ class Server(IServer):
username = self.params.username or nickname
realname = self.params.realname or nickname
alt_nicks = self.params.alt_nicknames
if not alt_nicks:
alt_nicks = [nickname+"_"*i for i in range(1, 4)]
self._alt_nicks = alt_nicks
# these must remain non-awaited; reading hasn't started yet
if not self.params.password is None:
self.send(build("PASS", [self.params.password]))
@ -161,11 +174,47 @@ class Server(IServer):
if line.command == "PING":
await self.send(build("PONG", line.params))
elif line.command == RPL_ENDOFWHO:
chan = self.casefold(line.params[1])
if (self._pending_who and
self._pending_who[0] == chan):
self._pending_who.popleft()
await self._next_who()
elif (line.command in {
ERR_NICKNAMEINUSE, ERR_ERRONEUSNICKNAME, ERR_UNAVAILRESOURCE
} and not self.registered):
if self._alt_nicks:
nick = self._alt_nicks.pop(0)
await self.send(build("NICK", [nick]))
else:
await self.send(build("QUIT"))
elif line.command in [RPL_ENDOFMOTD, ERR_NOMOTD]:
# we didn't get the nickname we wanted. watch for it if we can
if not self.nickname == self.params.nickname:
target = self.params.nickname
if self.isupport.monitor is not None:
await self.send(build("MONITOR", ["+", target]))
elif self.isupport.watch is not None:
await self.send(build("WATCH", [f"+{target}"]))
# has someone just stopped using the nickname we want?
elif line.command == RPL_LOGOFF:
await self._check_regain([line.params[1]])
elif line.command == RPL_MONOFFLINE:
await self._check_regain(line.params[1].split(","))
elif (line.command in ["NICK", "QUIT"] and
line.source is not None):
await self._check_regain([line.hostmask.nickname])
elif emit is not None:
if emit.command == "001":
if emit.command == RPL_WELCOME:
await self.send(build("WHO", [self.nickname]))
self.set_throttle(THROTTLE_RATE, THROTTLE_TIME)
if self.params.autojoin:
await self._batch_joins(self.params.autojoin)
elif emit.command == "CAP":
if emit.subcommand == "NEW":
await self._cap_ls(emit)
@ -178,76 +227,98 @@ class Server(IServer):
elif emit.command == "JOIN":
if emit.self and not emit.channel is None:
await self.send(build("MODE", [emit.channel.name]))
await WHOContext(self).ensure(emit.channel.name)
chan = emit.channel.name_lower
await self.send(build("MODE", [chan]))
modes = "".join(self.isupport.chanmodes.a_modes)
await self.send(build("MODE", [chan, f"+{modes}"]))
self._pending_who.append(chan)
if len(self._pending_who) == 1:
await self._next_who()
await self.line_read(line)
async def _next_lines(self) -> List[Tuple[Line, Optional[Emit]]]:
ping_sent = False
async def _check_regain(self, nicks: List[str]):
for nick in nicks:
if (self.casefold_equals(nick, self.params.nickname) and
not self.nickname == self.params.nickname):
await self.send(build("NICK", [self.params.nickname]))
async def _batch_joins(self,
channels: List[str],
batch_n: int=10):
#TODO: do as many JOINs in one line as we can fit
#TODO: channel keys
for i in range(0, len(channels), batch_n):
batch = channels[i:i+batch_n]
await self.send(build("JOIN", [",".join(batch)]))
async def _next_who(self):
if self._pending_who:
chan = self._pending_who[0]
if self.isupport.whox:
await self.send(self.prepare_whox(chan))
else:
await self.send(build("WHO", [chan]))
async def _read_line(self, timeout: float) -> Optional[Line]:
while True:
if self._read_queue:
return self._read_queue.popleft()
try:
async with timeout(PING_TIMEOUT):
async with timeout_(timeout):
data = await self._reader.read(1024)
except asyncio.TimeoutError:
if ping_sent:
data = b"" # empty data means the socket disconnected
else:
ping_sent = True
await self.send(build("PING", ["hello"]))
continue
self.last_read = monotonic()
ping_sent = False
try:
lines = self.recv(data)
except ServerDisconnectedException:
self.disconnected = True
raise
return lines
async def _line_or_wait(self,
line_aw: Awaitable
) -> Optional[Tuple[WaitFor, Awaitable]]:
wait_for_fut: Future[WaitFor] = Future()
self._wait_for_fut = wait_for_fut
done, pend = await asyncio.wait([line_aw, wait_for_fut],
return_when=asyncio.FIRST_COMPLETED)
if wait_for_fut.done():
new_line_aw = list(pend)[0]
return (await wait_for_fut), new_line_aw
else:
return None
async def _read_lines(self) -> List[Tuple[Line, Optional[Emit]]]:
lines = await self._next_lines()
for line, emit in lines:
self.last_read = monotonic()
lines = self.recv(data)
for line in lines:
self.line_preread(line)
self._read_queue.append(line)
for i, (wait_for, aw) in enumerate(self._wait_fors):
if wait_for.match(self, line):
wait_for.resolve(line)
async def _read_lines(self):
while True:
async with self._read_lguard:
pass
if aw is not None:
new_wait_for = await self._line_or_wait(aw)
if new_wait_for is not None:
self._wait_fors.append(new_wait_for)
self._wait_fors.pop(i)
break
if not self._process_queue:
async with self._read_lwork:
read_aw = asyncio.create_task(self._read_line(PING_TIMEOUT))
wait_aw = asyncio.create_task(self._wait_for.wait())
dones, notdones = await asyncio.wait(
[read_aw, wait_aw],
return_when=asyncio.FIRST_COMPLETED
)
self._wait_for.clear()
line_aw = self._on_read(line, emit)
new_wait_for = await self._line_or_wait(line_aw)
if new_wait_for is not None:
self._wait_fors.append(new_wait_for)
return lines
for done in dones:
if isinstance(done.result(), Line):
self._ping_sent = False
line = done.result()
emit = self.parse_tokens(line)
self._process_queue.append((line, emit))
elif done.result() is None:
if not self._ping_sent:
await self.send(build("PING", ["hello"]))
self._ping_sent = True
else:
await self.disconnect()
raise ServerDisconnectedException()
for notdone in notdones:
notdone.cancel()
else:
line, emit = self._process_queue.popleft()
await self._on_read(line, emit)
async def wait_for(self,
response: Union[IMatchResponse, Set[IMatchResponse]],
sent_aw: Optional[Awaitable[SentLine]]=None
sent_aw: Optional[Awaitable[SentLine]]=None,
timeout: float=WAIT_TIMEOUT
) -> Line:
response_obj: IMatchResponse
@ -256,28 +327,24 @@ class Server(IServer):
else:
response_obj = response
our_wait_for = WaitFor(response_obj)
wait_for_fut = self._wait_for_fut
if wait_for_fut is not None:
self._wait_for_fut = None
wait_for_fut.set_result(our_wait_for)
else:
self._wait_fors.append((our_wait_for, None))
if sent_aw is not None:
sent_line = await sent_aw
label = str(sent_line.id)
our_wait_for.with_label(label)
return (await our_wait_for)
async with self._read_lguard:
self._wait_for.set()
async with self._read_lwork:
async with timeout_(timeout):
while True:
line = await self._read_line(timeout)
if line:
self._ping_sent = False
emit = self.parse_tokens(line)
self._process_queue.append((line, emit))
if response_obj.match(self, line):
return line
async def _on_send_line(self, line: Line):
if (line.command == "PRIVMSG" and
if (line.command in ["PRIVMSG", "NOTICE", "TAGMSG"] and
not self.cap_agreed(CAP_ECHO)):
new_line = line.with_source(self.hostmask())
emit = self.parse_tokens(new_line)
self._read_queue.append((new_line, emit))
self._read_queue.append(new_line)
async def _send_lines(self):
while True:
@ -437,7 +504,9 @@ class Server(IServer):
fut = self.send(build("WHOIS", args))
async def _assure() -> Optional[Whois]:
params = [ANY, Folded(self.casefold(target))]
folded = self.casefold(target)
params = [ANY, Folded(folded)]
obj = Whois()
while True:
line = await self.wait_for(Responses([
@ -478,11 +547,14 @@ class Server(IServer):
symbols += channel[0]
channel = channel[1:]
channel_user = ChannelUser()
channel_user = ChannelUser(
Name(obj.nickname, folded),
Name(channel, self.casefold(channel))
)
for symbol in symbols:
mode = self.isupport.prefix.from_prefix(symbol)
if mode is not None:
channel_user.modes.append(mode)
channel_user.modes.add(mode)
obj.channels.append(channel_user)
elif line.command == RPL_ENDOFWHOIS:

View File

@ -1,10 +1,12 @@
from hashlib import sha512
from ssl import SSLContext
from typing import Optional, Tuple
from asyncio import StreamReader, StreamWriter
from async_stagger import open_connection
from .interface import ITCPTransport, ITCPReader, ITCPWriter
from .security import tls_context
from .security import (tls_context, TLS, TLSNoVerify, TLSVerifyHash,
TLSVerifySHA512)
class TCPReader(ITCPReader):
def __init__(self, reader: StreamReader):
@ -34,14 +36,16 @@ class TCPTransport(ITCPTransport):
async def connect(self,
hostname: str,
port: int,
tls: bool,
tls_verify: bool=True,
tls: Optional[TLS],
bindhost: Optional[str]=None
) -> Tuple[ITCPReader, ITCPWriter]:
cur_ssl: Optional[SSLContext] = None
if tls:
cur_ssl = tls_context(tls_verify)
if tls is not None:
cur_ssl = tls_context(not isinstance(tls, TLSNoVerify))
if tls.client_keypair is not None:
(client_cert, client_key) = tls.client_keypair
cur_ssl.load_cert_chain(client_cert, keyfile=client_key)
local_addr: Optional[Tuple[str, int]] = None
if not bindhost is None:
@ -55,5 +59,20 @@ class TCPTransport(ITCPTransport):
server_hostname=server_hostname,
ssl =cur_ssl,
local_addr =local_addr)
if isinstance(tls, TLSVerifyHash):
cert: bytes = writer.transport.get_extra_info(
"ssl_object"
).getpeercert(True)
if isinstance(tls, TLSVerifySHA512):
sum = sha512(cert).hexdigest()
else:
raise ValueError(f"unknown hash pinning {type(tls)}")
if not sum == tls.sum:
raise ValueError(
f"pinned hash for {hostname} does not match ({sum})"
)
return (TCPReader(reader), TCPWriter(writer))

View File

@ -1,6 +1,6 @@
anyio ==1.3.0
asyncio-throttle ==1.0.1
dataclasses ==0.6
ircstates ==0.9.18
async_stagger ==0.3.0
async_timeout ==3.0.1
anyio ~=2.0.2
asyncio-rlock ~=0.1.0
asyncio-throttle ~=1.0.1
ircstates ~=0.12.1
async_stagger ~=0.3.0
async_timeout ~=4.0.2

View File

@ -1,4 +1,4 @@
import setuptools
from setuptools import find_namespace_packages, setup
with open("README.md", "r") as fh:
long_description = fh.read()
@ -7,7 +7,7 @@ with open("VERSION", "r") as version_file:
with open("requirements.txt", "r") as requirements_file:
install_requires = requirements_file.read().splitlines()
setuptools.setup(
setup(
name="ircrobots",
version=version,
author="jesopo",
@ -16,7 +16,7 @@ setuptools.setup(
long_description=long_description,
long_description_content_type="text/markdown",
url="https://github.com/jesopo/ircrobots",
packages=["ircrobots"],
packages=["ircrobots"] + find_namespace_packages(include=["ircrobots.*"]),
package_data={"ircrobots": ["py.typed"]},
classifiers=[
"Programming Language :: Python :: 3",
@ -26,6 +26,6 @@ setuptools.setup(
"Operating System :: Microsoft :: Windows",
"Topic :: Communications :: Chat :: Internet Relay Chat"
],
python_requires='>=3.6',
python_requires='>=3.7',
install_requires=install_requires
)

View File

@ -3,14 +3,14 @@ from ircrobots import glob
class GlobTestCollapse(unittest.TestCase):
def test(self):
c1 = glob._collapse("**?*")
c1 = glob.collapse("**?*")
self.assertEqual(c1, "?*")
c2 = glob._collapse("a**?a*")
c2 = glob.collapse("a**?a*")
self.assertEqual(c2, "a?*a*")
c3 = glob._collapse("?*?*?*?*a")
c3 = glob.collapse("?*?*?*?*a")
self.assertEqual(c3, "????*a")
c4 = glob._collapse("a*?*a?**")
c4 = glob.collapse("a*?*a?**")
self.assertEqual(c4, "a?*a?*")