welcomebot 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2020 cwren
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: welcomebot
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: dotenv>=0.9.9
9
+ Requires-Dist: signalbot>=0.25.0
10
+ Dynamic: license-file
11
+
12
+ # json-rpc mode for the signalbot library
13
+
14
+ sudo docker run -d --name signal-api --restart=always -p 9922:8080 \
15
+ -v signal-state:/home/.local/share/signal-cli \
16
+ -e 'MODE=json-rpc' bbernhard/signal-cli-rest-api
17
+
18
+ docker container stop signal-api
19
+ docker container rm signal-api
20
+
21
+ - create .env with
22
+ - SIGNAL_SERVICE=localhost:9922
23
+ - PHONE_NUMBER The number of the signal account
24
+ - WELCOME_MANAGER The signal ID of the manager
25
+ - WELCOME_CNC The command and control group chat ID
26
+
27
+ uv sync
28
+ uv run pytest
29
+ uv run src/welcombot/main.py
30
+
31
+ if migrating an existing bot:
32
+ - signalbot_internal_state.db
33
+ - bot_memory.db
34
+
35
+ # native mode for the pysignalclirestapi library
36
+
37
+ sudo docker run -d --name signal-api --restart=always -p 9922:8080 \
38
+ -v signal-state:/home/.local/share/signal-cli \
39
+ -e 'MODE=native' bbernhard/signal-cli-rest-api
40
+
@@ -0,0 +1,29 @@
1
+ # json-rpc mode for the signalbot library
2
+
3
+ sudo docker run -d --name signal-api --restart=always -p 9922:8080 \
4
+ -v signal-state:/home/.local/share/signal-cli \
5
+ -e 'MODE=json-rpc' bbernhard/signal-cli-rest-api
6
+
7
+ docker container stop signal-api
8
+ docker container rm signal-api
9
+
10
+ - create .env with
11
+ - SIGNAL_SERVICE=localhost:9922
12
+ - PHONE_NUMBER The number of the signal account
13
+ - WELCOME_MANAGER The signal ID of the manager
14
+ - WELCOME_CNC The command and control group chat ID
15
+
16
+ uv sync
17
+ uv run pytest
18
+ uv run src/welcombot/main.py
19
+
20
+ if migrating an existing bot:
21
+ - signalbot_internal_state.db
22
+ - bot_memory.db
23
+
24
+ # native mode for the pysignalclirestapi library
25
+
26
+ sudo docker run -d --name signal-api --restart=always -p 9922:8080 \
27
+ -v signal-state:/home/.local/share/signal-cli \
28
+ -e 'MODE=native' bbernhard/signal-cli-rest-api
29
+
@@ -0,0 +1,23 @@
1
+ [project]
2
+ name = "welcomebot"
3
+ version = "0.1.0"
4
+ description = "Add your description here"
5
+ readme = "README.md"
6
+ requires-python = ">=3.14"
7
+ dependencies = [
8
+ "dotenv>=0.9.9",
9
+ "signalbot>=0.25.0",
10
+ ]
11
+
12
+ [dependency-groups]
13
+ dev = [
14
+ "pytest>=9.0.2",
15
+ "pytest-asyncio>=1.3.0",
16
+ ]
17
+
18
+ [project.scripts]
19
+ welcomebot = "welcomebot:main"
20
+
21
+ [tool.pytest.ini_options]
22
+ pythonpath = ["src"]
23
+ asyncio_mode = "auto"
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ from .cnc import CNCCommand
2
+ from .motd import MotDCommand
3
+ from .store import BotStore
@@ -0,0 +1,123 @@
1
+ import re
2
+
3
+ from signalbot import Command, Context, MessageType
4
+
5
+ HELP_MESSAGE = """you can use these commands:
6
+ list_groups: return known group names
7
+ set_motd: set_motd group <newline> message"""
8
+
9
+
10
+ class CNCCommand(Command):
11
+ def __init__(self, logger, managers, cnc, bs):
12
+ self.logger = logger
13
+ self.managers = managers
14
+ self.cnc = cnc
15
+ self.bs = bs
16
+
17
+ def _get_group_info(self):
18
+ my_group_ids = self.bs.list_groups()
19
+ known_groups = self.bot._groups_by_internal_id
20
+ group_ids = [ group_id for group_id in known_groups.keys() if group_id in my_group_ids]
21
+ groups = [ known_groups[group_id] for group_id in group_ids ]
22
+ group_info = [ { key: group[key] for key in ['name', 'internal_id'] } for group in groups ]
23
+ group_info = sorted(group_info, key=lambda x: x['name'])
24
+ for i, info in enumerate(group_info):
25
+ info['tag'] = i
26
+ return group_info
27
+
28
+ async def handle(self, context: Context) -> None:
29
+ if context.message.group != self.cnc: # guard against DMs
30
+ self.logger.info("cnc ignoring DM message")
31
+ reply = "I only reply to messages in the CNC channel\n"
32
+ await context.send(reply)
33
+ return
34
+
35
+ if context.message.type == MessageType.DATA_MESSAGE:
36
+ self.logger.info("cnc processing data message")
37
+ if context.message.source_uuid not in self.managers:
38
+ reply = "I only reply to messages from a manager\n"
39
+ await context.send(reply)
40
+ return
41
+ ops = context.message.text.split(maxsplit=2)
42
+ match(ops[0].lower()):
43
+ case 'help':
44
+ self.logger.info("cnc sending help message")
45
+ await context.send(HELP_MESSAGE)
46
+ return
47
+
48
+ case 'list_groups':
49
+ self.logger.info("cnc processing list request")
50
+ group_info = self._get_group_info()
51
+ reply = 'known groups:\n'
52
+ reply += '\n'.join([ f'{group['tag']}: {group["name"]}' for group in group_info ])
53
+ await context.send(reply)
54
+ return
55
+
56
+ case 'set_motd':
57
+ self.logger.info("cnc processing set_mod request")
58
+ if len(ops) < 2:
59
+ reply = f'unrecognized set_motd syntax'
60
+ await context.send(reply)
61
+ return
62
+
63
+ group_info = self._get_group_info()
64
+ group_tag = ops[1]
65
+ motd = ops[2] if len(ops) == 3 else None
66
+
67
+ try:
68
+ group_tag = int(group_tag)
69
+ except ValueError:
70
+ reply = f'invalid group index: {group_tag}'
71
+ await context.send(reply)
72
+ return
73
+
74
+ if group_tag > len(group_info):
75
+ reply = f'group index out of range: {group_tag}'
76
+ await context.send(reply)
77
+ return
78
+
79
+ group = group_info[group_tag]
80
+ self.bs.put_motd(group['internal_id'], motd)
81
+ if motd:
82
+ reply = f'motd set for group {group_tag} ({group['name']})'
83
+ else:
84
+ reply = f'motd cleared for group {group_tag} ({group['name']}'
85
+ await context.send(reply)
86
+ return
87
+
88
+ case 'get_motd':
89
+ self.logger.info("cnc processing get_mod request")
90
+ if len(ops) < 2:
91
+ reply = f'unrecognized set_motd syntax'
92
+ await context.send(reply)
93
+ return
94
+
95
+ group_info = self._get_group_info()
96
+ group_tag = ops[1]
97
+ motd = ops[2] if len(ops) == 3 else None
98
+
99
+ try:
100
+ group_tag = int(group_tag)
101
+ except ValueError:
102
+ reply = f'invalid group index: {group_tag}'
103
+ await context.send(reply)
104
+ return
105
+
106
+ if group_tag >= len(group_info):
107
+ reply = f'group index out of range: {group_tag}'
108
+ await context.send(reply)
109
+ return
110
+
111
+ group = group_info[group_tag]
112
+ motd = self.bs.get_motd(group['internal_id'])
113
+ if motd:
114
+ reply = f'motd for group {group_tag} ({group['name']}) is: \n{motd}'
115
+ else:
116
+ reply = f'there is no motd for group {group_tag} ({group['name']})'
117
+ await context.send(reply)
118
+ return
119
+
120
+
121
+ reply = """unknown command, type "help" for a list"""
122
+ await context.send(reply)
123
+ return
@@ -0,0 +1,48 @@
1
+ from dotenv import load_dotenv
2
+ import logging
3
+ import os
4
+ import re
5
+ from signalbot import SignalBot, Config, SQLiteConfig, enable_console_logging
6
+
7
+ import cnc
8
+ import motd
9
+ import store
10
+
11
+ logger = logging.getLogger("welcomebot")
12
+
13
+ def main():
14
+ bot = SignalBot(
15
+ Config(
16
+ signal_service=os.environ["SIGNAL_SERVICE"],
17
+ phone_number=os.environ["PHONE_NUMBER"],
18
+ storage=SQLiteConfig(
19
+ sqlite_db='signalbot_internal_state.db',
20
+ )
21
+ )
22
+ )
23
+
24
+ cnc_id = os.environ["WELCOME_CNC"]
25
+ managers = re.split(r'[\s|,:]+', os.environ["WELCOME_MANAGER"])
26
+
27
+ bot_store = store.BotStore(logger)
28
+ bot.register(cnc.CNCCommand(logger, managers, cnc_id, bot_store), groups=[cnc_id]) # monitor other groups
29
+ bot.register(motd.MotDCommand(logger, cnc_id, bot_store)) # monitor other groups
30
+ bot.start()
31
+ logger.info("bot started")
32
+
33
+ if __name__ == "__main__":
34
+ # signalbot logs
35
+ enable_console_logging(logging.WARNING)
36
+
37
+ # welcomebot logs
38
+ logger.setLevel(logging.DEBUG)
39
+ handler = logging.StreamHandler()
40
+ formatter = logging.Formatter(
41
+ "%(asctime)s %(name)s %(filename)s [%(levelname)s] - %(message)s"
42
+ )
43
+ handler.setFormatter(formatter)
44
+ logger.addHandler(handler)
45
+
46
+ load_dotenv()
47
+
48
+ main()
@@ -0,0 +1,58 @@
1
+ from signalbot import Command, Context, MessageType
2
+
3
+ class MotDCommand(Command):
4
+ def __init__(self, logger, cnc, bs):
5
+ self.logger = logger
6
+ self.cnc = cnc
7
+ self.bs = bs
8
+
9
+ async def handle(self, context: Context) -> None:
10
+ if context.message.group == self.cnc:
11
+ self.logger.info("social is ignoring cnc message")
12
+ return
13
+
14
+ if context.message.group == None:
15
+ self.logger.info("social rebuffing DM message")
16
+ reply = "I only reply to messages in the group chats"
17
+ await context.send(reply)
18
+ return
19
+
20
+ if context.message.type == MessageType.DATA_MESSAGE:
21
+ self.logger.info("social processing data message")
22
+ reply = f'I heard {context.message.text}'
23
+ self.logger.debug("social sending response")
24
+ await context.send(reply)
25
+ return
26
+
27
+ if context.message.type == MessageType.GROUP_UPDATE_MESSAGE:
28
+ self.logger.info("social processing group update")
29
+ post_group = self.bot._groups_by_internal_id[context.message.group]
30
+ prev_members = self.bs.get_members(context.message.group)
31
+ new_member = False
32
+ if prev_members:
33
+ for member in post_group["members"]:
34
+ self.logger.debug(f' looking for {member} in old group')
35
+ if member not in prev_members:
36
+ self.logger.debug(" found a new member of the group")
37
+ new_member = True
38
+ for member in prev_members:
39
+ self.logger.debug(f' looking for {member} in new group')
40
+ if member not in post_group["members"]:
41
+ self.logger.debug(" a member left the group")
42
+ else:
43
+ self.logger.debug(" found a new group")
44
+ # TODO post TOC
45
+
46
+ # update member cache
47
+ self.bs.put_members(context.message.group, post_group["members"])
48
+ self.bs.retain_only(list(self.bot._groups_by_internal_id.keys()))
49
+
50
+ if new_member:
51
+ motd = self.bs.get_motd(context.message.group)
52
+ # TODO don't send too frequently
53
+ if motd:
54
+ self.logger.info("sent the message of the day")
55
+ await context.send(motd)
56
+ else:
57
+ self.logger.info("no message of the day to send")
58
+ return
@@ -0,0 +1,84 @@
1
+ import sqlite3
2
+
3
+ class BotStore():
4
+ def __init__(self, logger, db="bot_memory.db"):
5
+ self.logger = logger
6
+ self.con = sqlite3.connect(db)
7
+ cur = self.con.cursor()
8
+ cur.execute("""
9
+ CREATE TABLE IF NOT EXISTS group_members (
10
+ group_id TEXT,
11
+ member_id TEXT
12
+ );
13
+ """)
14
+ self.con.commit()
15
+ cur = self.con.cursor()
16
+ cur.execute("""
17
+ CREATE TABLE IF NOT EXISTS motd (
18
+ group_id TEXT,
19
+ motd TEXT
20
+ );
21
+ """)
22
+ self.con.commit()
23
+ cur.close()
24
+
25
+ def __del__(self):
26
+ self.con.close()
27
+
28
+ def list_groups(self):
29
+ cur = self.con.cursor()
30
+ res = cur.execute('SELECT DISTINCT group_id FROM group_members')
31
+ rows = res.fetchall()
32
+ cur.close()
33
+ return [ row[0] for row in rows ]
34
+
35
+ def retain_only(self, known_groups):
36
+ # TODO also prune old groups
37
+ saved_groups = self.list_groups()
38
+ obsolete_groups = [ group for group in saved_groups if group not in known_groups]
39
+ if obsolete_groups:
40
+ self.logger.debug(f'dropping {len(obsolete_groups)} obsolete groups')
41
+ cur = self.con.cursor()
42
+ placeholders = ', '.join('?' for _ in obsolete_groups)
43
+ cur.executemany(f'DELETE FROM group_members WHERE group_id = ({placeholders})', obsolete_groups)
44
+ self.con.commit()
45
+ cur.close()
46
+ else:
47
+ self.logger.debug('no obsolete groups to prune')
48
+ return obsolete_groups
49
+
50
+ def get_members(self, group):
51
+ cur = self.con.cursor()
52
+ res = cur.execute(f'SELECT member_id FROM group_members WHERE group_id = "{group}"')
53
+ rows = res.fetchall()
54
+ cur.close()
55
+ return [ row[0] for row in rows ]
56
+
57
+ def put_members(self, group, members):
58
+ cur = self.con.cursor()
59
+ cur.execute(f'DELETE FROM group_members WHERE group_id = "{group}"')
60
+ self.con.commit()
61
+ rows = [ (group, member) for member in members ]
62
+ cur = self.con.cursor()
63
+ cur.executemany("INSERT INTO group_members VALUES(?, ?)", rows)
64
+ self.con.commit()
65
+ cur.close()
66
+
67
+ def get_motd(self, group):
68
+ cur = self.con.cursor()
69
+ res = cur.execute(f'SELECT motd FROM motd WHERE group_id = "{group}"')
70
+ row = res.fetchone()
71
+ cur.close()
72
+ return row[0] if row else None
73
+
74
+ def put_motd(self, group, motd):
75
+ cur = self.con.cursor()
76
+ cur.execute(f'DELETE FROM motd WHERE group_id = "{group}"')
77
+ self.con.commit()
78
+ if motd:
79
+ cur = self.con.cursor()
80
+ cur.executemany("INSERT INTO motd VALUES(?, ?)", [ ( group, motd ) ] )
81
+ self.con.commit()
82
+ cur.close()
83
+ return motd
84
+ return None
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.4
2
+ Name: welcomebot
3
+ Version: 0.1.0
4
+ Summary: Add your description here
5
+ Requires-Python: >=3.14
6
+ Description-Content-Type: text/markdown
7
+ License-File: LICENSE
8
+ Requires-Dist: dotenv>=0.9.9
9
+ Requires-Dist: signalbot>=0.25.0
10
+ Dynamic: license-file
11
+
12
+ # json-rpc mode for the signalbot library
13
+
14
+ sudo docker run -d --name signal-api --restart=always -p 9922:8080 \
15
+ -v signal-state:/home/.local/share/signal-cli \
16
+ -e 'MODE=json-rpc' bbernhard/signal-cli-rest-api
17
+
18
+ docker container stop signal-api
19
+ docker container rm signal-api
20
+
21
+ - create .env with
22
+ - SIGNAL_SERVICE=localhost:9922
23
+ - PHONE_NUMBER The number of the signal account
24
+ - WELCOME_MANAGER The signal ID of the manager
25
+ - WELCOME_CNC The command and control group chat ID
26
+
27
+ uv sync
28
+ uv run pytest
29
+ uv run src/welcombot/main.py
30
+
31
+ if migrating an existing bot:
32
+ - signalbot_internal_state.db
33
+ - bot_memory.db
34
+
35
+ # native mode for the pysignalclirestapi library
36
+
37
+ sudo docker run -d --name signal-api --restart=always -p 9922:8080 \
38
+ -v signal-state:/home/.local/share/signal-cli \
39
+ -e 'MODE=native' bbernhard/signal-cli-rest-api
40
+
@@ -0,0 +1,14 @@
1
+ LICENSE
2
+ README.md
3
+ pyproject.toml
4
+ src/welcomebot/__init__.py
5
+ src/welcomebot/cnc.py
6
+ src/welcomebot/main.py
7
+ src/welcomebot/motd.py
8
+ src/welcomebot/store.py
9
+ src/welcomebot.egg-info/PKG-INFO
10
+ src/welcomebot.egg-info/SOURCES.txt
11
+ src/welcomebot.egg-info/dependency_links.txt
12
+ src/welcomebot.egg-info/entry_points.txt
13
+ src/welcomebot.egg-info/requires.txt
14
+ src/welcomebot.egg-info/top_level.txt
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ welcomebot = welcomebot:main
@@ -0,0 +1,2 @@
1
+ dotenv>=0.9.9
2
+ signalbot>=0.25.0
@@ -0,0 +1 @@
1
+ welcomebot