Skip to content

Commit

Permalink
Fix file (#10)
Browse files Browse the repository at this point in the history
* fix file sender
* fix drafty2text
* add attachment api
* add image feature
* update readme
  • Loading branch information
Ovizro authored Feb 5, 2024
1 parent 412a0d6 commit 6f65048
Show file tree
Hide file tree
Showing 16 changed files with 460 additions and 187 deletions.
8 changes: 2 additions & 6 deletions .github/workflows/build_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ jobs:
build:
strategy:
matrix:
python-version: [3.8, 3.9, "3.10", "3.11"]
python-version: [3.8, 3.9, "3.10", "3.11", "3.12"]
os: [windows-latest, ubuntu-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 30
Expand All @@ -27,10 +27,6 @@ jobs:
run: |
python -m pip install --upgrade pip
pip install setuptools wheel
- name: Install typing-extensions
if: ${{ matrix.python-version == 3.6 }}
run: |
pip install typing-extensions==4.1.1
- name: Build dist and test with unittest
run: |
make build install test build_dist
make build install_all test build_dist
11 changes: 7 additions & 4 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
.PHONY: run install develop build_dist test coverage clean
.PHONY: run install install_all refresh uninstall develop build_dist test coverage clean

MODULE := karuha
PIP_MODULE := KaruhaBot

all: clean lint build_dist
all: clean test lint build_dist
refresh: clean develop test lint

run:
Expand All @@ -12,14 +12,17 @@ run:
build:
python setup.py build

build_dist:
build_dist: test
python setup.py sdist bdist_wheel

install:
pip install .

install_all:
pip install .[all]

develop:
pip install -e .
pip install -e .[all]

lint:
flake8 ${MODULE}/ tests/ --exclude __init__.py --count --max-line-length=127 --extend-ignore=W293,E402
Expand Down
44 changes: 33 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

[![License](https://img.shields.io/github/license/Ovizro/Karuha.svg)](LICENSE)
[![PyPI](https://img.shields.io/pypi/v/KaruhaBot.svg)](https://pypi.python.org/pypi/KaruhaBot)
![Python Version](https://img.shields.io/badge/python-3.8%20|%203.9%20|%203.10%20|%203.11-blue.svg)
[![Build Status](https://github.com/Ovizro/Karuha//actions/workflows/build_test.yml/badge.svg)](https://github.com/Ovizro/Karuha/actions)
![Python Version](https://img.shields.io/badge/python-3.8%20|%203.9%20|%203.10%20|%203.11%20|%203.12-blue.svg)

A simple Tinode chatbot framework.

The name of the library `Karuha` comes from the character Karuha Ramukone (カルハ・ラムコネ) in the game 星空鉄道とシロの旅.

<center>
<div align="center">

![Karuha](https://raw.githubusercontent.com/Visecy/Karuha/master/docs/img/tw_icon-karuha2.png)

</center>
</div>

> カルハ・ラムコネと申します。カルハちゃんって呼んでいいわよ
Expand Down Expand Up @@ -76,17 +77,17 @@ async def hi(session: MessageSession) -> None:
await session.send("Hello!")
```

The above code involves some Python knowledge, I will briefly introduce it one by one. If you already know this knowledge, you can skip this part.
The above code involves some Python knowledge, which I will briefly introduce one by one. If you already understand these concepts, you can skip this part.

In the first line of code, we import the `on_command` decorator and `MessageSession` class from the `karuha` module. A decorator is an object that can be used to decorate a function or class. Here, its usage is as shown in the fourth line, where the function is modified with `@on_command` before the function definition. The modified function will be registered as a command and will be called when the corresponding message is received.
The first line imports the `on_command` decorator and the `MessageSession` class from the karuha module. Decorators are objects that can be used to decorate functions or classes. Here, its usage is shown on line 4, decorating the function with `@on_command` before the function definition. The decorated function will be registered as a command and called when the corresponding message is received.

Next is the definition of the `hi` function. Here we use `async def` to define the command function. Unlike ordinary functions defined using `def`, functions defined with `async def` are asynchronous functions. Asynchronous is a relatively complex topic. It doesn't matter if you don't understand it. Here we will only use some simple syntax similar to normal functions.
Next is the definition of the hi function. Here we use `async def` to define the command function. Unlike functions defined using `def`, functions defined with `async def` are asynchronous functions. Asynchrony is a complex topic. If you don't understand it, that's fine - we will only use some simple syntax here similar to normal functions.

You may be unfamiliar with the line `(session: MessageSession) -> None`. This is a type annotation that describes the parameter types and return value types of a function. Here we declare that the type of `session` is `MessageSession`, and the return value type is `None`, that is, there is no return value. In Python, type annotations are optional, but for commands in Karuha, they are used for parsing message data. Although not required, it is recommended to add type annotations when writing commands so that Karuha can better understand your code.
You may be unfamiliar with line `(session: MessageSession) -> None`. This is a type annotation to indicate the parameter type and return value type of the function. Here we declare the type of session to be `MessageSession`, and the return type to be `None`, meaning no return value. Type annotations are optional in Python but recommended for Karuha commands to help parse message data.

Then there is the content of the function, which is very short and only one line. `session` is a session object, which encapsulates many APIs for receiving and sending messages. Here, we use the `send` method to send the message. `send` is an asynchronous method, so you need to use `await` in front when calling it.
Then comes the function body, which is very short with only one line. `session` is a session object that encapsulates many APIs for receiving and sending messages. Here we use the `send` method to send a message. `send` is an asynchronous method, so we need to use await when calling it.

After finishing writing the command, we can run the chatbot to test it. Run the chatbot using the following command:
After writing the command, we can run the chatbot to test it. Use the following command:

```sh
python -m Karuha ./config.json -m hi
Expand All @@ -99,7 +100,7 @@ Then in the conversation with the bot, enter the following:

> By default, karuha will only process text messages starting with `/` as commands. This behavior can be set through the `set_prefix` function before defining all commands.
If everything goes well, you should see the 'Hello!' reply from the bot.
If everything goes well, you should see the `Hello!` reply from the bot.

### Getting User Input

Expand Down Expand Up @@ -144,12 +145,33 @@ Features that may be added in the future include:
- [ ] APIs related to user information getting and setting
- [ ] Automatic argument parsing in argparse format for commands

### Module Development
Currently, Karuha's support for module development is relatively simple. There are no dedicated APIs for defining chatbot modules, but predefined commands can still be supported.

The way to define commands in external modules is similar to the normal definition. But to avoid affecting the user's related command settings, we need to create a new CommandCollection. The method to establish a command collection and define commands in it is as follows:

```python
from karuha import MessageSession
from karuha.command import new_collection, add_sub_collection


collection = new_collection()
add_sub_collection(collection)


@collection.on_command
async def hi(session: MessageSession, text: str) -> None:
...
```

> Note that to make the command collection take effect, the add_sub_collection function needs to be called to add the command collection to the sub-command collection.
### Architecture Overview
The overall architecture of Karuha is as follows:

| Layer | Provided Module | Function |
| --- | --- | --- |
| Upper layer | karuha.command | 命Command registration and processing |
| Upper layer | karuha.command | Command registration and processing |
| Middle layer | karuha.event | Async event-driven system |
| Lower layer | karuha.bot | Tinode API basic encapsulation |

Expand Down
29 changes: 26 additions & 3 deletions README_cn.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

[![License](https://img.shields.io/github/license/Ovizro/Karuha.svg)](/LICENSE)
[![PyPI](https://img.shields.io/pypi/v/KaruhaBot.svg)](https://pypi.python.org/pypi/KaruhaBot)
![Python Version](https://img.shields.io/badge/python-3.8%20|%203.9%20|%203.10%20|%203.11-blue.svg)
[![Build Status](https://github.com/Ovizro/Karuha//actions/workflows/build_test.yml/badge.svg)](https://github.com/Ovizro/Karuha/actions)
![Python Version](https://img.shields.io/badge/python-3.8%20|%203.9%20|%203.10%20|%203.11%20|%203.12-blue.svg)

一个简单的Tinode聊天机器人框架

库的名称`Karuha`来自游戏星空列车与白的旅行中的角色 狩叶·朗姆柯妮(カルハ・ラムコネ)

<center>
<div align="center">

![Karuha](/docs/img/tw_icon-karuha2.png)

</center>
</div>

> カルハ・ラムコネと申します。カルハちゃんって呼んでいいわよ
Expand Down Expand Up @@ -148,6 +149,28 @@ async def hi(session: MessageSession, text: str) -> None:
- [ ] 用户信息获取与设置相关的API
- [ ] argparse格式的命令参数自动解析

### 模块开发

目前,karuha对模块开发的支持较为简单。没有专门用于聊天机器人模块定义的API,但如果只是预设一些命令还是可以支持的。

在外部模块定义命令的方式与正常的定义方式类似。但为了避免影响用户的相关命令设置,我们需要新建一个命令集合(CommandCollection)。建立命令集合并在其中定义命令的方法如下:

```python
from karuha import MessageSession
from karuha.command import new_collection, add_sub_collection


collection = new_collection()
add_sub_collection(collection)


@collection.on_command
async def hi(session: MessageSession, text: str) -> None:
...
```

> 注意,为了使命令集合生效,需要调用`add_sub_collection`函数将命令集合添加到子命令集合中。
### 架构说明

Karuha的总体架构如下:
Expand Down
3 changes: 2 additions & 1 deletion karuha/command/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from .command import AbstractCommand, FunctionCommand, ParamFunctionCommand
from .collection import (CommandCollection, add_sub_collection, get_collection,
remove_sub_collection, reset_collection,
new_collection, remove_sub_collection, reset_collection,
set_collection, set_collection_factory, set_prefix)
from .decoractor import on_command
from .parser import (AbstractCommandNameParser, ParamParser, ParamParserFlag,
Expand Down Expand Up @@ -28,6 +28,7 @@
# collection
"CommandCollection",
"get_collection",
"new_collection",
"add_sub_collection",
"remove_sub_collection",
"reset_collection",
Expand Down
41 changes: 28 additions & 13 deletions karuha/command/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,13 @@
from time import time
from typing import Any, Dict, Optional, Tuple, Union

from aiofiles import open as aio_open

from ..bot import Bot
from ..event.bot import PublishEvent
from ..event.message import Message, MessageDispatcher, get_message_lock
from ..exception import KaruhaRuntimeError
from ..text import BaseText, Drafty
from ..text.textchain import Bold, Button, Form, PlainText, TextChain, File, NewLine
from ..text.textchain import (Bold, Button, File, Form, Image, NewLine, PlainText,
TextChain)
from ..utils.dispatcher import FutureDispatcher
from ..utils.event_catcher import EventCatcher

Expand All @@ -34,7 +33,7 @@ async def send(
if isinstance(text, BaseText):
text = text.to_drafty()
if isinstance(text, Drafty):
text = text.model_dump()
text = text.model_dump(exclude_defaults=True)
head = head or {}
head["mime"] = "text/x-drafty"
with EventCatcher(PublishEvent) as catcher:
Expand All @@ -51,17 +50,33 @@ async def send(

async def send_file(
self,
path: Union[str, bytes, os.PathLike],
path: Union[str, os.PathLike],
/, *,
name: Optional[str] = None
) -> Optional[int]: # pragma: no cover
async with aio_open(path, "rb") as f:
data = await f.read()
file = File(
raw_value=data, # type: ignore
name=name
name: Optional[str] = None,
mime: Optional[str] = None,
**kwds: Any
) -> Optional[int]:
return await self.send(
await File.from_file(
path, name=name, mime=mime
),
**kwds
)

async def send_image(
self,
path: Union[str, os.PathLike],
/, *,
name: Optional[str] = None,
mime: Optional[str] = None,
**kwds: Any
) -> Optional[int]:
return await self.send(
await Image.from_file(
path, name=name, mime=mime
),
**kwds
)
return await self.send(file)

async def wait_reply(
self,
Expand Down
11 changes: 9 additions & 2 deletions karuha/event/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,16 @@
from .message import MessageEvent, MessageDispatcher, get_message_lock


def ensure_text_len(text: str, length: int = 128) -> str:
if len(text) < length:
return text
tail_length = length // 4
return f"{text[:length-tail_length]} ... {text[-tail_length:]}"


@DataEvent.add_handler
async def _(event: DataEvent) -> None:
event.bot.logger.info(f"({event.topic})=> {event.text}")
event.bot.logger.info(f"({event.topic})=> {ensure_text_len(event.text)}")
MessageEvent.from_data_event(event).trigger()
await event.bot.note_read(event.topic, event.seq_id)

Expand Down Expand Up @@ -38,7 +45,7 @@ async def _(event: LoginEvent) -> None:

@PublishEvent.add_handler
async def _(event: PublishEvent) -> None:
event.bot.logger.info(f"({event.topic})<= {event.text}")
event.bot.logger.info(f"({event.topic})<= {ensure_text_len(event.text)}")


@MessageEvent.add_handler
Expand Down
44 changes: 21 additions & 23 deletions karuha/text/convert.py
Original file line number Diff line number Diff line change
Expand Up @@ -112,40 +112,27 @@ def _default_converter(text: str, span: Span) -> BaseText: # pragma: no cover
return PlainText(text=text)


def _split_text(text: str, /, spans: List[Span], start: int = 0, end: int = -1) -> List[BaseText]:
def _split_text(text: str, /, spans: List[Span], start: int = 0, end: int = -1) -> BaseText:
last = start
raw_contents = []
chain = TextChain()
for i in spans:
if last < i.start:
raw_contents.append(PlainText(text[last:i.start]))
chain += text[last:i.start]
last = i.end
raw_contents.append(_convert(text, i))
chain += _convert(text, i)
if end < 0:
end = len(text)
if last < end:
raw_contents.append(PlainText(text[last:]))

iter_raw = iter(raw_contents)
contents = [next(iter_raw)]
for i in iter_raw:
if isinstance(i, PlainText) and isinstance(contents[-1], PlainText):
contents[-1].text += i.text
elif isinstance(i, TextChain):
contents.extend(i.contents)
else:
contents.append(i)
return contents
chain += text[last:]
return chain.take()


def _convert_spans(text: str, spans: Optional[List[Span]], /, start: int, end: int) -> BaseText:
if not spans:
return PlainText(text=text[start:end])
elif spans[0].start == start and spans[0].end == end:
return _convert(text, spans[0])
content = _split_text(text, spans, start, end)
if len(content) == 1:
return content[0]
return TextChain(*content)
return _split_text(text, spans, start, end)


def _container_converter(text: str, span: Span) -> BaseText:
Expand Down Expand Up @@ -184,14 +171,25 @@ def FM_converter(text: str, span: Span) -> BaseText:
return Form(content=content, **(span.data or {}))


def drafty2tree_ex(drafty: Drafty) -> Tuple[List[Span], List[DraftyExtend]]:
spans, attachments = eval_spans(drafty)
spans = to_span_tree(spans)
return spans, attachments


def drafty2tree(drafty: Drafty) -> List[Span]:
spans, _ = eval_spans(drafty)
return to_span_tree(spans)
return drafty2tree_ex(drafty)[0]


def tree2text(text: str, spans: List[Span]) -> BaseText:
return _convert_spans(text, spans, 0, len(text))


def drafty2text(drafty: Drafty) -> BaseText:
return tree2text(drafty.txt, drafty2tree(drafty))
spans, attachments = drafty2tree_ex(drafty)
text = tree2text(drafty.txt, spans)
for i in attachments:
text += _ExtensionText.tp_map[i.tp](**i.data)
if isinstance(text, TextChain):
return text.take()
return text
2 changes: 1 addition & 1 deletion karuha/text/drafty.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class DraftyExtend(BaseModel, frozen=True):


class Drafty(BaseModel):
txt: str
txt: str = ''
fmt: List[DraftyFormat] = []
ent: List[DraftyExtend] = []

Expand Down
Loading

0 comments on commit 6f65048

Please sign in to comment.