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):
self.response = response
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)
await self.add_server(server.name, server.params)
# /methods designed to be overridden
reconnect = server.params.reconnect
while True:
await asyncio.sleep(reconnect)
try:
await self.add_server(server.name, server.params)
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):
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._send_lines)
try:
async with anyio.create_task_group() as tg:
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):
@ -24,11 +25,10 @@ class ITCPWriter(object):
class ITCPTransport(object):
async def connect(self,
hostname: str,
port: int,
tls: bool,
tls_verify: bool=True,
bindhost: Optional[str]=None
hostname: str,
port: int,
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
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
@ -25,9 +27,10 @@ from .interface import (IBot, ICapability, IServer, SentLine, SendPriority,
IMatchResponse)
from .interface import ITCPTransport, ITCPReader, ITCPWriter
THROTTLE_RATE = 4 # lines
THROTTLE_TIME = 2 # seconds
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
@ -115,9 +124,8 @@ class Server(IServer):
reader, writer = await transport.connect(
params.host,
params.port,
tls =params.tls,
tls_verify=params.tls_verify,
bindhost =params.bindhost)
tls =params.tls,
bindhost =params.bindhost)
self._reader = reader
self._writer = writer
@ -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
return None
self.last_read = monotonic()
ping_sent = False
self.last_read = monotonic()
lines = self.recv(data)
for line in lines:
self.line_preread(line)
self._read_queue.append(line)
try:
lines = self.recv(data)
except ServerDisconnectedException:
self.disconnected = True
raise
async def _read_lines(self):
while True:
async with self._read_lguard:
pass
return lines
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()
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
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()
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.line_preread(line)
for i, (wait_for, aw) in enumerate(self._wait_fors):
if wait_for.match(self, line):
wait_for.resolve(line)
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
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
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):
@ -32,16 +34,18 @@ class TCPWriter(ITCPWriter):
class TCPTransport(ITCPTransport):
async def connect(self,
hostname: str,
port: int,
tls: bool,
tls_verify: bool=True,
bindhost: Optional[str]=None
hostname: str,
port: int,
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?*")