many: monorepo-ise

This commit is contained in:
Mark Joshwel 2024-06-18 18:53:38 +08:00
parent d64eb987a9
commit 5737d22237
31 changed files with 2377 additions and 1501 deletions

2
.gitignore vendored
View file

@ -1,3 +1,5 @@
old/*
# cached files # cached files
__pycache__/ __pycache__/
*.py[cod] *.py[cod]

View file

@ -1,8 +0,0 @@
# surplus changelog
this is temporary and will be used to keep track of breaking changes as 2024.0.0 is developed
## 2024.0.0
- `default_geocoder()` and `default_reverser()` have been deprecated since v2.1.0 and are now removed. use the `SurplusDefaultGeocoding` class instead
- `SurplusException` is now `SurplusError`

View file

@ -1,116 +0,0 @@
# the contributors guide to surplus
- [git workflow](#git-workflow)
- [reporting incorrect output](#reporting-incorrect-output)
- [the reporting process](#the-reporting-process)
- [what counts as "incorrect"](#what-counts-as-incorrect)
also see the [DEVELOPING.md](/DEVELOPING.md) file for more information on the codebase.
## git workflow
1. fork the repository and branch off from the `future` branch,
or `main` if `future` is not available
2. make and commit your changes!
3. pull in any changes from upstream, and resolve any conflicts, if any
4. **commit your copyright waiver** (_see below_)
5. submit a pull request (_or mail in a diff_)
when contributing your first changes, please include an empty commit for a copyright
waiver using the following message (replace 'Your Name' with your name or nickname):
```text
Your Name Copyright Waiver
I dedicate any and all copyright interest in this software to the
public domain. I make this dedication for the benefit of the public at
large and to the detriment of my heirs and successors. I intend this
dedication to be an overt act of relinquishment in perpetuity of all
present and future rights to this software under copyright law.
```
the command to create an empty commit is `git commit --allow-empty`
## reporting incorrect output
> [!NOTE]
> this section is independent of the rest of the contributing section.
different output from the iOS Shortcuts app is expected, however incorrect output is not.
### the reporting process
open an issue in the
[repositories issue tracker](https://github.com/markjoshwel/surplus/issues/new),
and do the following:
1. ensure that your issue is not an error of incorrect data returned by your reverser
function, which by default is OpenStreetMap Nominatim.
(_don't know what the above means? then you are using the default reverser._)
also look at the ['what counts as "incorrect"'](#what-counts-as-incorrect) section
before moving on.
2. include the erroneous query.
(_the Plus Code/local code/latlong coordinate/query string you passed into surplus_)
3. include output from the terminal with the
[`--debug` flag](/README.md#command-line-usage) passed to the surplus CLI or with
`debug=True` set in function calls.
> [!NOTE]
> if you are using the surplus API and have passed custom stdout and stderr parameters
> to redirect output, include that instead.
4. how it should look like instead, with reasoning if the error is not obvious. (e.g.,
missing details)
for reference, see how the following issues were written:
- [issue #4: "Incorrect format: repeated lines"](https://github.com/markjoshwel/surplus/issues/4)
- [issue #6: "Incorrect format: missing details"](https://github.com/markjoshwel/surplus/issues/6)
- [issue #12: "Incorrect format: State before county"](https://github.com/markjoshwel/surplus/issues/12)
#### what counts as "incorrect"
- **example** (correct)
- iOS Shortcuts Output
```text
Plaza Singapura
68 Orchard Rd
238839
Singapore
```
- surplus Output
```text
Plaza Singapura
68 Orchard Road
Museum
238839
Central, Singapore
```
this _should not_ be reported as incorrect, as the only difference between the two is
that surplus displays more information.
other examples that _should not_ be reported are:
- name of place is incorrect/different
this may be due to incorrect data from the geocoder function, which is OpenStreetMap
Nominatim by default. in the case of Nominatim, it means that the data on OpenStreetMap
is incorrect.
(_if so, then consider updating OpenStreetMap to help not just you, but other surplus
and OpenStreetMap users!_)
**you should report** when the output does not make logical sense, or something similar
wherein the output of surplus is illogical to read or is not correct in the traditional
sense of a correct address.
see the linked issues in [the reporting process](#the-reporting-process) for examples
of incorrect outputs.

View file

@ -1,316 +0,0 @@
# the developers guide to surplus
- [quickstart](#quickstart)
- [common commands](#common-commands)
- [the technical details of surplus's output](#the-technical-details-of-surpluss-output)
## quickstart
prerequisites:
- [Python >=3.11](https://www.python.org/)
- [Hatch](https://hatch.pypa.io/latest/)
alternatively, use [devbox](https://get.jetpack.io/devbox) for a hermetic development environment powered by [Nix](https://nixos.org/).
```text
devbox shell # skip this if you aren't using devbox
hatch shell
```
## common commands
- `hatch fmt`
formats and statically analyses the codebase
- `hatch run dev:check`
runs mypy and isort to check the codebase
- `hatch run dev:fix`
runs isort to fix imports
## the technical details of surplus's output
> [!NOTE]
> this is a breakdown of surplus's output when converting to shareable text.
> when converting to other output types, output may be different.
```text
$ s+ --debug 8QJF+RP Singapore
surplus version 2.2.0, debug mode (latest@future, Tue 05 Sep 2023 23:38:59 +0800)
debug: parse_query: behaviour.query=['8QJF+RP', 'Singapore']
debug: _match_plus_code: portion_plus_code='8QJF+RP', portion_locality='Singapore'
debug: cli: query=Result(value=LocalCodeQuery(code='8QJF+RP', locality='Singapore'), error=None)
debug: latlong_result.get()=Latlong(latitude=1.3320625, longitude=103.7743125)
debug: location={...}
debug: _generate_text: split_iso3166_2=['SG', '03']
debug: _generate_text: using special key arrangements for 'SG-03' (Singapore)
debug: _generate_text: seen_names=['Ngee Ann Polytechnic', 'Clementi Road']
debug: _generate_text_line: [True] -> True -------- 'Ngee Ann Polytechnic'
debug: _generate_text_line: [True] -> True -------- '535'
debug: _generate_text_line: [True] -> True -------- 'Clementi Road'
debug: _generate_text_line: [True, True] -> True -------- 'Bukit Timah'
debug: _generate_text_line: [False, True] -> False filtered 'Singapore'
debug: _generate_text_line: [True] -> True -------- '599489'
debug: _generate_text_line: [True] -> True -------- 'Northwest'
debug: _generate_text_line: [True] -> True -------- 'Singapore'
0 Ngee Ann Polytechnic
1
2
3 535 Clementi Road
4 Bukit Timah
5 599489
6 Northwest, Singapore
Ngee Ann Polytechnic
535 Clementi Road
Bukit Timah
599489
Northwest, Singapore
```
variables
- **variables `behaviour.query`, `split_query` and `original_query`**
(_`split_query` and `original_query` are only shown if query is a latlong coordinate
or query string_)
`behaviour.query` is the original query string or a list of strings from space-splitting the original query
string passed to [`parse_query()`](/README.md#def-parse_query) for parsing
`split_query` is the original query string split by spaces
`original_query` is a single non-split string
```text
$ s+ Temasek Polytechnic
-------------------
query
behaviour.query -> ['Temasek', 'Polytechnic']
split_query -> ['Temasek', 'Polytechnic']
original_query -> 'Temasek Polytechnic'
```
```text
>>> surplus("77Q4+7X Austin, Texas, USA", surplus.Behaviour())
behaviour.query -> '77Q4+7X Austin, Texas, USA'
split_query -> ['77Q4+7X', 'Austin,', 'Texas,', 'USA']
original_query -> '77Q4+7X Austin, Texas, USA'
```
- **variables `portion_plus_code` and `portion_locality`**
(_only shown if the query is a local code, not shown on full-length Plus Codes,
latlong coordinates or string queries_)
represents the Plus Code and locality portions of a
[shortened Plus Code](https://en.wikipedia.org/wiki/Open_Location_Code#Common_usage_and_shortening)
(_referred to as a "local code" in the codebase_) respectively
- **variable `query`**
query is a variable of type [`Result[Query]`](/README.md#query)
this variable is displayed to show what query type [`parse_query()`](/README.md#def-parse_query) has
recognised, and if there were any errors during query parsing
- **expression `latlong_result.get()=`**
(_only shown if the query is a Plus Code_)
the latitude longitude coordinates derived from the Plus Code
- **variable `location`**
the response dictionary from the reverser function passed to
[`surplus()`](/README.md#def-surplus)
for more information on the reverser function, see
[`SurplusReverserProtocol`](/README.md#surplusreverserprotocol)
- **variable `split_iso3166_2` and special key arrangements**
a list of strings containing the split iso3166-2 code (country/subdivision identifier)
if special key arrangements are available for the code, a line similar to the following
will be shown:
```text
debug: _generate_text: using special key arrangements for 'SG-03' (Singapore)
```
- **variable `seen_names`**
a list of unique important names found in certain Nominatim keys used in final output
lines 0-3
- **`_generate_text_line` seen name checks**
```text
# filter function boolean list status element
# ============================= ======== ======================
debug: _generate_text_line: [True] -> True -------- 'Ngee Ann Polytechnic'
debug: _generate_text_line: [False, True] -> False filtered 'Singapore'
```
a check is done on shareable text line 4 keys (`SHAREABLE_TEXT_LINE_4_KEYS` - general
regional location) to reduce repeated elements found in `seen_names`
reasoning is, if an element on line 4 (general regional location) is the exact same as
a previously seen name, there is no need to include the element
- **filter function boolean list**
`_generate_text_line`, an internal function defined inside `_generate_text` can be
passed a filter function as a way to filter out certain elements on a line
```python
# the filter used in _generate_text, for line 4's seen name checks
filter=lambda ak: [
# everything here should be True if the element is to be kept
ak not in general_global_info,
not any(True if (ak in sn) else False for sn in seen_names),
]
```
`general_global_info` is a list of strings containing elements from line 6. (general
global information)
- **status**
what `all(filter(detail))` evaluates to, `filter` being the filter function passed to
`_generate_text_line` and `detail` being the current element
- **element**
the current iteration from iterating through a list of strings containing elements
from line 4. (general regional location)
line breakdown of shareable text output, accompanied by their Nominatim keys:
```text
0 name of a place
1 building name
2 highway name
3 block/house/building number, house name, road
4 general regional location
5 postal code
6 general global information
```
0. **name of a place**
(_usually important places or landmarks_)
- examples
```text
The University of Queensland
Ngee Ann Polytechnic
Botanic Gardens
```
- nominatim keys
```text
emergency, historic, military, natural, landuse, place, railway, man_made,
aerialway, boundary, amenity, aeroway, club, craft, leisure, office, mountain_pass,
shop, tourism, bridge, tunnel, waterway
```
1. **building name**
- examples
```text
Novena Square Office Tower A
Visitor Centre
```
- nominatim keys
```text
building
```
2. **highway name**
- examples
```text
Marina Coastal Expressway
Lornie Highway
```
- nominatim keys
```text
highway
```
3. **block/house/building number, house name, road**
- examples
```text
535 Clementi Road
Macquarie Street
Braddell Road
```
- nominatim keys
```text
house_number, house_name, road
```
4. **general regional location**
- examples
```text
St Lucia, Greater Brisbane
The Drag, Austin
Toa Payoh Crest
```
- nominatim keys
```text
residential, neighbourhood, allotments, quarter, city_district, district, borough,
suburb, subdivision, municipality, city, town, village
```
5. **postal code**
- examples
```text
310131
78705
4066
```
- nominatim key
```text
postcode
```
6. **general global information**
- examples
```text
Travis County, Texas, United States
Southeast, Singapore
Queensland, Australia
```
- nominatim keys
```text
region, county, state, state_district, country, continent
```

1090
README.md

File diff suppressed because it is too large Load diff

View file

@ -1,10 +0,0 @@
{
"packages": [
"python@3.11.8",
"hatch@latest",
"ruff@latest"
],
"nixpkgs": {
"commit": "f80ac848e3d6f0c12c52758c0f25c10c97ca3b62"
}
}

View file

@ -1,51 +0,0 @@
{
"lockfile_version": "1",
"packages": {
"hatch@latest": {
"last_modified": "2024-03-19T05:49:19Z",
"resolved": "github:NixOS/nixpkgs/5710127d9693421e78cca4f74fac2db6d67162b1#hatch",
"source": "devbox-search",
"version": "1.9.0",
"systems": {
"x86_64-linux": {
"store_path": "/nix/store/nxmypvfw8wxjnrx2ngq3dwd2pj2i07q7-hatch-1.9.0"
}
}
},
"python@3.11.8": {
"last_modified": "2024-03-22T11:26:23Z",
"plugin_version": "0.0.3",
"resolved": "github:NixOS/nixpkgs/a3ed7406349a9335cb4c2a71369b697cecd9d351#python3",
"source": "devbox-search",
"version": "3.11.8",
"systems": {
"aarch64-darwin": {
"store_path": "/nix/store/c05vbvkjxarxkws9zkwrcwrzlsx9nd68-python3-3.11.8"
},
"aarch64-linux": {
"store_path": "/nix/store/pxzzyri1wbq7kc7pain665g94afkl4ww-python3-3.11.8"
},
"x86_64-darwin": {
"store_path": "/nix/store/1zaap1xxxvw2ypsgh1mfxb3wzdd49873-python3-3.11.8"
},
"x86_64-linux": {
"store_path": "/nix/store/7wz6hm9i8wljz0hgwz1wqmn2zlbgavrq-python3-3.11.8"
}
}
},
"ruff@latest": {
"last_modified": "2024-03-22T11:26:23Z",
"resolved": "github:NixOS/nixpkgs/a3ed7406349a9335cb4c2a71369b697cecd9d351#ruff",
"source": "devbox-search",
"version": "0.3.2",
"systems": {
"aarch64-darwin": {
"store_path": "/nix/store/s3xi1kwxzq7q33pbx8af908724mis2m2-ruff-0.3.2"
},
"x86_64-darwin": {
"store_path": "/nix/store/f8327vcf7gr5jhj8rh2rp9cadm15ac37-ruff-0.3.2"
}
}
}
}
}

View file

@ -34,16 +34,18 @@ surplus = "surplus:cli"
exclude = [ exclude = [
"/.github", "/.github",
"/.devbox", "/.devbox",
"/src/surplus-on-wheels",
"/src/spow*",
] ]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["surplus"] packages = ["src.surplus"]
[project.urls] [project.urls]
Documentation = "https://github.com/markjoshwel/surplus#readme" Documentation = "https://joshwel.co/surplus"
Issues = "https://github.com/markjoshwel/surplus/issues" Issues = "https://joshwel.co/surplus/issues"
Source = "https://github.com/markjoshwel/surplus" Source = "https://github.com/markjoshwel/surplus"
Changelog = "https://github.com/markjoshwel/surplus/releases" Changelog = "https://joshwel.co/surplus/changelog"
[tool.ruff] [tool.ruff]
line-length = 100 line-length = 100
@ -54,7 +56,7 @@ line_length = 100
profile = "black" profile = "black"
[tool.hatch.version] [tool.hatch.version]
path = "surplus/surplus.py" path = "src/surplus/surplus.py"
[[tool.hatch.envs.all.matrix]] [[tool.hatch.envs.all.matrix]]
python = ["3.11", "3.12"] python = ["3.11", "3.12"]

View file

@ -36,7 +36,7 @@ from subprocess import run
from sys import exit as sysexit from sys import exit as sysexit
# NOTE: change this if surplus has moved # NOTE: change this if surplus has moved
path_surplus = Path(__file__).parent.joinpath("./surplus/surplus.py") path_surplus = Path(__file__).parent.joinpath("./src/surplus/surplus.py")
build_time = datetime.now(timezone(timedelta(hours=8))) # using SGT build_time = datetime.now(timezone(timedelta(hours=8))) # using SGT
@ -47,16 +47,17 @@ _insert_build_branch = getenv(
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
).stdout.strip("\n"), ).stdout.strip("\n").strip(),
) )
insert_build_branch = _insert_build_branch if _insert_build_branch != "" else "unknown" insert_build_branch = _insert_build_branch if _insert_build_branch != "" else "unknown"
insert_build_commit: str = run( _insert_build_commit: str = run(
"git rev-parse HEAD".split(), "git rev-parse HEAD".split(),
capture_output=True, capture_output=True,
text=True, text=True,
check=False, check=False,
).stdout.strip("\n") ).stdout.strip("\n").strip()
insert_build_commit = _insert_build_commit if _insert_build_commit != "" else "unknown"
insert_build_datetime: str = repr(build_time).replace("datetime.", "") insert_build_datetime: str = repr(build_time).replace("datetime.", "")

View file

@ -1,8 +0,0 @@
geographiclib==2.0 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:6b7225248e45ff7edcee32becc4e0a1504c606ac5ee163a5656d482e0cd38734 \
--hash=sha256:f7f41c85dc3e1c2d3d935ec86660dc3b2c848c83e17f9a9e51ba9d5146a15859
geopy==2.4.0 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:a59392bf17adb486b25dbdd71fbed27733bdf24a2dac588047a619de56695e36 \
--hash=sha256:d2639a46d0ce4c091e9688b750ba94348a14b898a1e55c68f4b4a07e7d1afa20
pluscodes==2022.1.3 ; python_version >= "3.11" and python_version < "4.0" \
--hash=sha256:50625f472f8d4e8822e005180c2eb41bf09e45e429f362d3cded346f1169dae8

51
src/spow-telegram-bridge/.gitignore vendored Normal file
View file

@ -0,0 +1,51 @@
.helper
*.session
*.session-journal
# cached files
__pycache__/
*.py[cod]
*$py.class
# distribution
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
pip-wheel-metadata/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
*.so
MANIFEST
pip-log.txt
pip-delete-this-directory.txt
# venv
.python-version
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# nix
.devbox
result

View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

View file

@ -0,0 +1,226 @@
#!/usr/bin/env python3
"""
s+ow-telegram-bridge: add-on bridge for surplus on wheels (s+ow) to telegram
----------------------------------------------------------------------------
by mark <mark@joshwel.co>
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>
"""
import asyncio
from datetime import datetime
from os import environ, chdir
from pathlib import Path
from sys import argv, stderr, stdin
from traceback import print_tb
from typing import Generic, NamedTuple, TypeVar
from telethon import TelegramClient # type: ignore
from telethon.tl import functions # type: ignore
# exit codes:
# 1 - bad command usage or missing env vars
# 2 - bad target
# 3 - could not send message
# rundown:
# 1. if argv[-1] is 'login', then run login() and exit
# 2. read stdin and comma split it
# 3. read ~/.cache/s+ow/message
# 4. for each target in comma split stdin that starts with "tg:",
# send ~/.cache/s+ow/message
dir_data: Path = Path.home().joinpath(".local/share/s+ow-telegram-bridge")
dir_data.mkdir(parents=True, exist_ok=True)
chdir(dir_data)
dir_cache: Path = Path.home().joinpath(".cache/s+ow-telegram-bridge")
dir_cache.mkdir(parents=True, exist_ok=True)
session = "s+ow-telegram-bridge"
api_id = environ.get("SPOW_TELEGRAM_API_ID", None)
api_hash = environ.get("SPOW_TELEGRAM_API_HASH", None)
message = Path.home().joinpath(".cache/s+ow/message")
def handle_error(
exc: Exception | None = None,
message: str = "error",
recoverable: bool = False,
exit_code: int = -1,
) -> None:
try:
exc_details: str = ""
if isinstance(exc, Exception):
exc_details = f": {exc} ({exc.__class__.__name__})"
print_tb(exc.__traceback__, file=stderr)
print(
f"s+ow-telegram-bridge: {message}{exc_details}",
file=stderr,
)
except Exception as exc:
pass
if not recoverable:
exit(exit_code)
def validate_vars() -> None:
if api_id is None:
print("s+ow-telegram-bridge: error: SPOW_TELEGRAM_API_ID not set", file=stderr)
exit(1)
if api_hash is None:
print("s+ow-telegram-bridge: error: SPOW_TELEGRAM_API_HASH not set", file=stderr)
exit(1)
if not (message.exists() and message.is_file()):
print("s+ow-telegram-bridge: error: ~/.cache/s+ow/message not found", file=stderr)
exit(1)
async def run() -> None:
validate_vars()
silent: bool = "--silent" in argv
delete_last: bool = "--delete-last" in argv
if silent:
print("s+ow-telegram-bridge: info: --silent passed", file=stderr)
if delete_last:
print("s+ow-telegram-bridge: info: --delete-last passed", file=stderr)
targets: list[int] = []
for line in stdin:
for _target in line.split(","):
if (_target := _target.strip()).startswith("tg:"):
_target = _target[3:]
if not (
_target.isnumeric()
or (_target.startswith("-") and _target.lstrip("-").isnumeric())
):
continue
try:
targets.append(int(_target))
except Exception as exc:
handle_error(
exc=exc,
message=f"error: could not cast '{_target}' as int",
recoverable=True,
exit_code=2,
)
continue
async with TelegramClient(session, api_id, api_hash) as client:
for target in targets:
try:
if delete_last is False:
await client.send_message(
int(target),
message.read_text(encoding="utf-8"),
silent=silent,
)
else:
target_persist: Path = dir_cache.joinpath(str(target))
try:
# delete old message if persist file exists
if target_persist.exists() and target_persist.is_file():
await client.delete_messages(
entity=target,
message_ids=[int(target_persist.read_text(encoding="utf-8"))],
)
except Exception as exc:
handle_error(
exc=exc,
message=f"error: could not delete old message",
recoverable=True,
exit_code=3,
)
continue
# send new message
target_sent_message = await client.send_message(
target,
message.read_text(),
silent=silent,
)
# persist new message id
target_persist.write_text(str(target_sent_message.id), encoding="utf-8")
except Exception as exc:
handle_error(
exc=exc,
message=f"error: could not send message",
recoverable=True,
exit_code=3,
)
continue
print("s+ow-telegram-bridge: success: message sent to", target)
exit()
def login() -> None:
validate_vars()
with TelegramClient(session, api_id, api_hash) as client:
client.start()
exit()
def list_chats() -> None:
validate_vars()
with TelegramClient(session, api_id, api_hash) as client:
for dialog in client.iter_dialogs():
print(dialog.id, "\t", dialog.name)
exit()
def entry() -> None:
if len(argv) < 1:
print("s+ow-telegram-bridge: error: len(argv) < 1", file=stderr)
exit(1)
if "login" in argv:
login()
elif "list" in argv:
list_chats()
else:
asyncio.run(run())
if __name__ == "__main__":
entry()

244
src/spow-telegram-bridge/poetry.lock generated Normal file
View file

@ -0,0 +1,244 @@
# This file is automatically @generated by Poetry 1.6.1 and should not be changed by hand.
[[package]]
name = "black"
version = "23.10.1"
description = "The uncompromising code formatter."
optional = false
python-versions = ">=3.8"
files = [
{file = "black-23.10.1-cp310-cp310-macosx_10_16_arm64.whl", hash = "sha256:ec3f8e6234c4e46ff9e16d9ae96f4ef69fa328bb4ad08198c8cee45bb1f08c69"},
{file = "black-23.10.1-cp310-cp310-macosx_10_16_x86_64.whl", hash = "sha256:1b917a2aa020ca600483a7b340c165970b26e9029067f019e3755b56e8dd5916"},
{file = "black-23.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c74de4c77b849e6359c6f01987e94873c707098322b91490d24296f66d067dc"},
{file = "black-23.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:7b4d10b0f016616a0d93d24a448100adf1699712fb7a4efd0e2c32bbb219b173"},
{file = "black-23.10.1-cp311-cp311-macosx_10_16_arm64.whl", hash = "sha256:b15b75fc53a2fbcac8a87d3e20f69874d161beef13954747e053bca7a1ce53a0"},
{file = "black-23.10.1-cp311-cp311-macosx_10_16_x86_64.whl", hash = "sha256:e293e4c2f4a992b980032bbd62df07c1bcff82d6964d6c9496f2cd726e246ace"},
{file = "black-23.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7d56124b7a61d092cb52cce34182a5280e160e6aff3137172a68c2c2c4b76bcb"},
{file = "black-23.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:3f157a8945a7b2d424da3335f7ace89c14a3b0625e6593d21139c2d8214d55ce"},
{file = "black-23.10.1-cp38-cp38-macosx_10_16_arm64.whl", hash = "sha256:cfcce6f0a384d0da692119f2d72d79ed07c7159879d0bb1bb32d2e443382bf3a"},
{file = "black-23.10.1-cp38-cp38-macosx_10_16_x86_64.whl", hash = "sha256:33d40f5b06be80c1bbce17b173cda17994fbad096ce60eb22054da021bf933d1"},
{file = "black-23.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:840015166dbdfbc47992871325799fd2dc0dcf9395e401ada6d88fe11498abad"},
{file = "black-23.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:037e9b4664cafda5f025a1728c50a9e9aedb99a759c89f760bd83730e76ba884"},
{file = "black-23.10.1-cp39-cp39-macosx_10_16_arm64.whl", hash = "sha256:7cb5936e686e782fddb1c73f8aa6f459e1ad38a6a7b0e54b403f1f05a1507ee9"},
{file = "black-23.10.1-cp39-cp39-macosx_10_16_x86_64.whl", hash = "sha256:7670242e90dc129c539e9ca17665e39a146a761e681805c54fbd86015c7c84f7"},
{file = "black-23.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ed45ac9a613fb52dad3b61c8dea2ec9510bf3108d4db88422bacc7d1ba1243d"},
{file = "black-23.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:6d23d7822140e3fef190734216cefb262521789367fbdc0b3f22af6744058982"},
{file = "black-23.10.1-py3-none-any.whl", hash = "sha256:d431e6739f727bb2e0495df64a6c7a5310758e87505f5f8cde9ff6c0f2d7e4fe"},
{file = "black-23.10.1.tar.gz", hash = "sha256:1f8ce316753428ff68749c65a5f7844631aa18c8679dfd3ca9dc1a289979c258"},
]
[package.dependencies]
click = ">=8.0.0"
mypy-extensions = ">=0.4.3"
packaging = ">=22.0"
pathspec = ">=0.9.0"
platformdirs = ">=2"
[package.extras]
colorama = ["colorama (>=0.4.3)"]
d = ["aiohttp (>=3.7.4)"]
jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]
uvloop = ["uvloop (>=0.15.2)"]
[[package]]
name = "click"
version = "8.1.7"
description = "Composable command line interface toolkit"
optional = false
python-versions = ">=3.7"
files = [
{file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"},
{file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"},
]
[package.dependencies]
colorama = {version = "*", markers = "platform_system == \"Windows\""}
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "isort"
version = "5.12.0"
description = "A Python utility / library to sort Python imports."
optional = false
python-versions = ">=3.8.0"
files = [
{file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"},
{file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"},
]
[package.extras]
colors = ["colorama (>=0.4.3)"]
pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"]
plugins = ["setuptools"]
requirements-deprecated-finder = ["pip-api", "pipreqs"]
[[package]]
name = "mypy"
version = "1.6.1"
description = "Optional static typing for Python"
optional = false
python-versions = ">=3.8"
files = [
{file = "mypy-1.6.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e5012e5cc2ac628177eaac0e83d622b2dd499e28253d4107a08ecc59ede3fc2c"},
{file = "mypy-1.6.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:d8fbb68711905f8912e5af474ca8b78d077447d8f3918997fecbf26943ff3cbb"},
{file = "mypy-1.6.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21a1ad938fee7d2d96ca666c77b7c494c3c5bd88dff792220e1afbebb2925b5e"},
{file = "mypy-1.6.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:b96ae2c1279d1065413965c607712006205a9ac541895004a1e0d4f281f2ff9f"},
{file = "mypy-1.6.1-cp310-cp310-win_amd64.whl", hash = "sha256:40b1844d2e8b232ed92e50a4bd11c48d2daa351f9deee6c194b83bf03e418b0c"},
{file = "mypy-1.6.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:81af8adaa5e3099469e7623436881eff6b3b06db5ef75e6f5b6d4871263547e5"},
{file = "mypy-1.6.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8c223fa57cb154c7eab5156856c231c3f5eace1e0bed9b32a24696b7ba3c3245"},
{file = "mypy-1.6.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a8032e00ce71c3ceb93eeba63963b864bf635a18f6c0c12da6c13c450eedb183"},
{file = "mypy-1.6.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:4c46b51de523817a0045b150ed11b56f9fff55f12b9edd0f3ed35b15a2809de0"},
{file = "mypy-1.6.1-cp311-cp311-win_amd64.whl", hash = "sha256:19f905bcfd9e167159b3d63ecd8cb5e696151c3e59a1742e79bc3bcb540c42c7"},
{file = "mypy-1.6.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:82e469518d3e9a321912955cc702d418773a2fd1e91c651280a1bda10622f02f"},
{file = "mypy-1.6.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d4473c22cc296425bbbce7e9429588e76e05bc7342da359d6520b6427bf76660"},
{file = "mypy-1.6.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:59a0d7d24dfb26729e0a068639a6ce3500e31d6655df8557156c51c1cb874ce7"},
{file = "mypy-1.6.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:cfd13d47b29ed3bbaafaff7d8b21e90d827631afda134836962011acb5904b71"},
{file = "mypy-1.6.1-cp312-cp312-win_amd64.whl", hash = "sha256:eb4f18589d196a4cbe5290b435d135dee96567e07c2b2d43b5c4621b6501531a"},
{file = "mypy-1.6.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:41697773aa0bf53ff917aa077e2cde7aa50254f28750f9b88884acea38a16169"},
{file = "mypy-1.6.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7274b0c57737bd3476d2229c6389b2ec9eefeb090bbaf77777e9d6b1b5a9d143"},
{file = "mypy-1.6.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbaf4662e498c8c2e352da5f5bca5ab29d378895fa2d980630656178bd607c46"},
{file = "mypy-1.6.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:bb8ccb4724f7d8601938571bf3f24da0da791fe2db7be3d9e79849cb64e0ae85"},
{file = "mypy-1.6.1-cp38-cp38-win_amd64.whl", hash = "sha256:68351911e85145f582b5aa6cd9ad666c8958bcae897a1bfda8f4940472463c45"},
{file = "mypy-1.6.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:49ae115da099dcc0922a7a895c1eec82c1518109ea5c162ed50e3b3594c71208"},
{file = "mypy-1.6.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:8b27958f8c76bed8edaa63da0739d76e4e9ad4ed325c814f9b3851425582a3cd"},
{file = "mypy-1.6.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:925cd6a3b7b55dfba252b7c4561892311c5358c6b5a601847015a1ad4eb7d332"},
{file = "mypy-1.6.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8f57e6b6927a49550da3d122f0cb983d400f843a8a82e65b3b380d3d7259468f"},
{file = "mypy-1.6.1-cp39-cp39-win_amd64.whl", hash = "sha256:a43ef1c8ddfdb9575691720b6352761f3f53d85f1b57d7745701041053deff30"},
{file = "mypy-1.6.1-py3-none-any.whl", hash = "sha256:4cbe68ef919c28ea561165206a2dcb68591c50f3bcf777932323bc208d949cf1"},
{file = "mypy-1.6.1.tar.gz", hash = "sha256:4d01c00d09a0be62a4ca3f933e315455bde83f37f892ba4b08ce92f3cf44bcc1"},
]
[package.dependencies]
mypy-extensions = ">=1.0.0"
typing-extensions = ">=4.1.0"
[package.extras]
dmypy = ["psutil (>=4.0)"]
install-types = ["pip"]
reports = ["lxml"]
[[package]]
name = "mypy-extensions"
version = "1.0.0"
description = "Type system extensions for programs checked with the mypy type checker."
optional = false
python-versions = ">=3.5"
files = [
{file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"},
{file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"},
]
[[package]]
name = "packaging"
version = "23.2"
description = "Core utilities for Python packages"
optional = false
python-versions = ">=3.7"
files = [
{file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
{file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
]
[[package]]
name = "pathspec"
version = "0.11.2"
description = "Utility library for gitignore style pattern matching of file paths."
optional = false
python-versions = ">=3.7"
files = [
{file = "pathspec-0.11.2-py3-none-any.whl", hash = "sha256:1d6ed233af05e679efb96b1851550ea95bbb64b7c490b0f5aa52996c11e92a20"},
{file = "pathspec-0.11.2.tar.gz", hash = "sha256:e0d8d0ac2f12da61956eb2306b69f9469b42f4deb0f3cb6ed47b9cce9996ced3"},
]
[[package]]
name = "platformdirs"
version = "3.11.0"
description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"."
optional = false
python-versions = ">=3.7"
files = [
{file = "platformdirs-3.11.0-py3-none-any.whl", hash = "sha256:e9d171d00af68be50e9202731309c4e658fd8bc76f55c11c7dd760d023bda68e"},
{file = "platformdirs-3.11.0.tar.gz", hash = "sha256:cf8ee52a3afdb965072dcc652433e0c7e3e40cf5ea1477cd4b3b1d2eb75495b3"},
]
[package.extras]
docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.1)", "sphinx-autodoc-typehints (>=1.24)"]
test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4)", "pytest-cov (>=4.1)", "pytest-mock (>=3.11.1)"]
[[package]]
name = "pyaes"
version = "1.6.1"
description = "Pure-Python Implementation of the AES block-cipher and common modes of operation"
optional = false
python-versions = "*"
files = [
{file = "pyaes-1.6.1.tar.gz", hash = "sha256:02c1b1405c38d3c370b085fb952dd8bea3fadcee6411ad99f312cc129c536d8f"},
]
[[package]]
name = "pyasn1"
version = "0.5.0"
description = "Pure-Python implementation of ASN.1 types and DER/BER/CER codecs (X.208)"
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
files = [
{file = "pyasn1-0.5.0-py2.py3-none-any.whl", hash = "sha256:87a2121042a1ac9358cabcaf1d07680ff97ee6404333bacca15f76aa8ad01a57"},
{file = "pyasn1-0.5.0.tar.gz", hash = "sha256:97b7290ca68e62a832558ec3976f15cbf911bf5d7c7039d8b861c2a0ece69fde"},
]
[[package]]
name = "rsa"
version = "4.9"
description = "Pure-Python RSA implementation"
optional = false
python-versions = ">=3.6,<4"
files = [
{file = "rsa-4.9-py3-none-any.whl", hash = "sha256:90260d9058e514786967344d0ef75fa8727eed8a7d2e43ce9f4bcf1b536174f7"},
{file = "rsa-4.9.tar.gz", hash = "sha256:e38464a49c6c85d7f1351b0126661487a7e0a14a50f1675ec50eb34d4f20ef21"},
]
[package.dependencies]
pyasn1 = ">=0.1.3"
[[package]]
name = "telethon"
version = "1.32.0"
description = "Full-featured Telegram client library for Python 3"
optional = false
python-versions = ">=3.5"
files = [
{file = "Telethon-1.32.0.tar.gz", hash = "sha256:a7efe0da9002e545236d636833e29e1be460e268d20ca49d03f8b9e8c9dab82a"},
]
[package.dependencies]
pyaes = "*"
rsa = "*"
[package.extras]
cryptg = ["cryptg"]
[[package]]
name = "typing-extensions"
version = "4.8.0"
description = "Backported and Experimental Type Hints for Python 3.8+"
optional = false
python-versions = ">=3.8"
files = [
{file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"},
{file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"},
]
[metadata]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "be959138054d5d36d8a880f957864c6968bee660b7e8d376f1694b96274295ec"

View file

@ -0,0 +1,34 @@
[tool.poetry]
name = "spow-telegram-bridge"
version = "0.1.0"
description = "add-on bridge for surplus on wheels (s+ow) to telegram"
authors = ["Mark Joshwel <mark@joshwel.co>"]
license = "Unlicense"
readme = "README.md"
packages = [
{include = "bridge.py"}
]
[tool.poetry.dependencies]
python = "^3.11"
Telethon = "^1.32.0"
[tool.poetry.group.dev.dependencies]
black = "^23.10.1"
mypy = "^1.6.1"
isort = "^5.12.0"
[tool.poetry.scripts]
spow-telegram-bridge = 'bridge:entry'
"s+ow-telegram-bridge" = 'bridge:entry'
[tool.black]
line-length = 90
[tool.isort]
line_length = 90
profile = "black"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"

7
src/spow-whatsapp-bridge/.gitignore vendored Normal file
View file

@ -0,0 +1,7 @@
spow-whatsapp-bridge
mdtest.db
dist
# nix
.devbox
result

View file

@ -0,0 +1,374 @@
Mozilla Public License Version 2.0
==================================
1. Definitions
--------------
1.1. "Contributor"
means each individual or legal entity that creates, contributes to
the creation of, or owns Covered Software.
1.2. "Contributor Version"
means the combination of the Contributions of others (if any) used
by a Contributor and that particular Contributor's Contribution.
1.3. "Contribution"
means Covered Software of a particular Contributor.
1.4. "Covered Software"
means Source Code Form to which the initial Contributor has attached
the notice in Exhibit A, the Executable Form of such Source Code
Form, and Modifications of such Source Code Form, in each case
including portions thereof.
1.5. "Incompatible With Secondary Licenses"
means
(a) that the initial Contributor has attached the notice described
in Exhibit B to the Covered Software; or
(b) that the Covered Software was made available under the terms of
version 1.1 or earlier of the License, but not also under the
terms of a Secondary License.
1.6. "Executable Form"
means any form of the work other than Source Code Form.
1.7. "Larger Work"
means a work that combines Covered Software with other material, in
a separate file or files, that is not Covered Software.
1.8. "License"
means this document.
1.9. "Licensable"
means having the right to grant, to the maximum extent possible,
whether at the time of the initial grant or subsequently, any and
all of the rights conveyed by this License.
1.10. "Modifications"
means any of the following:
(a) any file in Source Code Form that results from an addition to,
deletion from, or modification of the contents of Covered
Software; or
(b) any new file in Source Code Form that contains any Covered
Software.
1.11. "Patent Claims" of a Contributor
means any patent claim(s), including without limitation, method,
process, and apparatus claims, in any patent Licensable by such
Contributor that would be infringed, but for the grant of the
License, by the making, using, selling, offering for sale, having
made, import, or transfer of either its Contributions or its
Contributor Version.
1.12. "Secondary License"
means either the GNU General Public License, Version 2.0, the GNU
Lesser General Public License, Version 2.1, the GNU Affero General
Public License, Version 3.0, or any later versions of those
licenses.
1.13. "Source Code Form"
means the form of the work preferred for making modifications.
1.14. "You" (or "Your")
means an individual or a legal entity exercising rights under this
License. For legal entities, "You" includes any entity that
controls, is controlled by, or is under common control with You. For
purposes of this definition, "control" means (a) the power, direct
or indirect, to cause the direction or management of such entity,
whether by contract or otherwise, or (b) ownership of more than
fifty percent (50%) of the outstanding shares or beneficial
ownership of such entity.
2. License Grants and Conditions
--------------------------------
2.1. Grants
Each Contributor hereby grants You a world-wide, royalty-free,
non-exclusive license:
(a) under intellectual property rights (other than patent or trademark)
Licensable by such Contributor to use, reproduce, make available,
modify, display, perform, distribute, and otherwise exploit its
Contributions, either on an unmodified basis, with Modifications, or
as part of a Larger Work; and
(b) under Patent Claims of such Contributor to make, use, sell, offer
for sale, have made, import, and otherwise transfer either its
Contributions or its Contributor Version.
2.2. Effective Date
The licenses granted in Section 2.1 with respect to any Contribution
become effective for each Contribution on the date the Contributor first
distributes such Contribution.
2.3. Limitations on Grant Scope
The licenses granted in this Section 2 are the only rights granted under
this License. No additional rights or licenses will be implied from the
distribution or licensing of Covered Software under this License.
Notwithstanding Section 2.1(b) above, no patent license is granted by a
Contributor:
(a) for any code that a Contributor has removed from Covered Software;
or
(b) for infringements caused by: (i) Your and any other third party's
modifications of Covered Software, or (ii) the combination of its
Contributions with other software (except as part of its Contributor
Version); or
(c) under Patent Claims infringed by Covered Software in the absence of
its Contributions.
This License does not grant any rights in the trademarks, service marks,
or logos of any Contributor (except as may be necessary to comply with
the notice requirements in Section 3.4).
2.4. Subsequent Licenses
No Contributor makes additional grants as a result of Your choice to
distribute the Covered Software under a subsequent version of this
License (see Section 10.2) or under the terms of a Secondary License (if
permitted under the terms of Section 3.3).
2.5. Representation
Each Contributor represents that the Contributor believes its
Contributions are its original creation(s) or it has sufficient rights
to grant the rights to its Contributions conveyed by this License.
2.6. Fair Use
This License is not intended to limit any rights You have under
applicable copyright doctrines of fair use, fair dealing, or other
equivalents.
2.7. Conditions
Sections 3.1, 3.2, 3.3, and 3.4 are conditions of the licenses granted
in Section 2.1.
3. Responsibilities
-------------------
3.1. Distribution of Source Form
All distribution of Covered Software in Source Code Form, including any
Modifications that You create or to which You contribute, must be under
the terms of this License. You must inform recipients that the Source
Code Form of the Covered Software is governed by the terms of this
License, and how they can obtain a copy of this License. You may not
attempt to alter or restrict the recipients' rights in the Source Code
Form.
3.2. Distribution of Executable Form
If You distribute Covered Software in Executable Form then:
(a) such Covered Software must also be made available in Source Code
Form, as described in Section 3.1, and You must inform recipients of
the Executable Form how they can obtain a copy of such Source Code
Form by reasonable means in a timely manner, at a charge no more
than the cost of distribution to the recipient; and
(b) You may distribute such Executable Form under the terms of this
License, or sublicense it under different terms, provided that the
license for the Executable Form does not attempt to limit or alter
the recipients' rights in the Source Code Form under this License.
3.3. Distribution of a Larger Work
You may create and distribute a Larger Work under terms of Your choice,
provided that You also comply with the requirements of this License for
the Covered Software. If the Larger Work is a combination of Covered
Software with a work governed by one or more Secondary Licenses, and the
Covered Software is not Incompatible With Secondary Licenses, this
License permits You to additionally distribute such Covered Software
under the terms of such Secondary License(s), so that the recipient of
the Larger Work may, at their option, further distribute the Covered
Software under the terms of either this License or such Secondary
License(s).
3.4. Notices
You may not remove or alter the substance of any license notices
(including copyright notices, patent notices, disclaimers of warranty,
or limitations of liability) contained within the Source Code Form of
the Covered Software, except that You may alter any license notices to
the extent required to remedy known factual inaccuracies.
3.5. Application of Additional Terms
You may choose to offer, and to charge a fee for, warranty, support,
indemnity or liability obligations to one or more recipients of Covered
Software. However, You may do so only on Your own behalf, and not on
behalf of any Contributor. You must make it absolutely clear that any
such warranty, support, indemnity, or liability obligation is offered by
You alone, and You hereby agree to indemnify every Contributor for any
liability incurred by such Contributor as a result of warranty, support,
indemnity or liability terms You offer. You may include additional
disclaimers of warranty and limitations of liability specific to any
jurisdiction.
4. Inability to Comply Due to Statute or Regulation
---------------------------------------------------
If it is impossible for You to comply with any of the terms of this
License with respect to some or all of the Covered Software due to
statute, judicial order, or regulation then You must: (a) comply with
the terms of this License to the maximum extent possible; and (b)
describe the limitations and the code they affect. Such description must
be placed in a text file included with all distributions of the Covered
Software under this License. Except to the extent prohibited by statute
or regulation, such description must be sufficiently detailed for a
recipient of ordinary skill to be able to understand it.
5. Termination
--------------
5.1. The rights granted under this License will terminate automatically
if You fail to comply with any of its terms. However, if You become
compliant, then the rights granted under this License from a particular
Contributor are reinstated (a) provisionally, unless and until such
Contributor explicitly and finally terminates Your grants, and (b) on an
ongoing basis, if such Contributor fails to notify You of the
non-compliance by some reasonable means prior to 60 days after You have
come back into compliance. Moreover, Your grants from a particular
Contributor are reinstated on an ongoing basis if such Contributor
notifies You of the non-compliance by some reasonable means, this is the
first time You have received notice of non-compliance with this License
from such Contributor, and You become compliant prior to 30 days after
Your receipt of the notice.
5.2. If You initiate litigation against any entity by asserting a patent
infringement claim (excluding declaratory judgment actions,
counter-claims, and cross-claims) alleging that a Contributor Version
directly or indirectly infringes any patent, then the rights granted to
You by any and all Contributors for the Covered Software under Section
2.1 of this License shall terminate.
5.3. In the event of termination under Sections 5.1 or 5.2 above, all
end user license agreements (excluding distributors and resellers) which
have been validly granted by You or Your distributors under this License
prior to termination shall survive termination.
************************************************************************
* *
* 6. Disclaimer of Warranty *
* ------------------------- *
* *
* Covered Software is provided under this License on an "as is" *
* basis, without warranty of any kind, either expressed, implied, or *
* statutory, including, without limitation, warranties that the *
* Covered Software is free of defects, merchantable, fit for a *
* particular purpose or non-infringing. The entire risk as to the *
* quality and performance of the Covered Software is with You. *
* Should any Covered Software prove defective in any respect, You *
* (not any Contributor) assume the cost of any necessary servicing, *
* repair, or correction. This disclaimer of warranty constitutes an *
* essential part of this License. No use of any Covered Software is *
* authorized under this License except under this disclaimer. *
* *
************************************************************************
************************************************************************
* *
* 7. Limitation of Liability *
* -------------------------- *
* *
* Under no circumstances and under no legal theory, whether tort *
* (including negligence), contract, or otherwise, shall any *
* Contributor, or anyone who distributes Covered Software as *
* permitted above, be liable to You for any direct, indirect, *
* special, incidental, or consequential damages of any character *
* including, without limitation, damages for lost profits, loss of *
* goodwill, work stoppage, computer failure or malfunction, or any *
* and all other commercial damages or losses, even if such party *
* shall have been informed of the possibility of such damages. This *
* limitation of liability shall not apply to liability for death or *
* personal injury resulting from such party's negligence to the *
* extent applicable law prohibits such limitation. Some *
* jurisdictions do not allow the exclusion or limitation of *
* incidental or consequential damages, so this exclusion and *
* limitation may not apply to You. *
* *
************************************************************************
8. Litigation
-------------
Any litigation relating to this License may be brought only in the
courts of a jurisdiction where the defendant maintains its principal
place of business and such litigation shall be governed by laws of that
jurisdiction, without reference to its conflict-of-law provisions.
Nothing in this Section shall prevent a party's ability to bring
cross-claims or counter-claims.
9. Miscellaneous
----------------
This License represents the complete agreement concerning the subject
matter hereof. If any provision of this License is held to be
unenforceable, such provision shall be reformed only to the extent
necessary to make it enforceable. Any law or regulation which provides
that the language of a contract shall be construed against the drafter
shall not be used to construe this License against a Contributor.
10. Versions of the License
---------------------------
10.1. New Versions
Mozilla Foundation is the license steward. Except as provided in Section
10.3, no one other than the license steward has the right to modify or
publish new versions of this License. Each version will be given a
distinguishing version number.
10.2. Effect of New Versions
You may distribute the Covered Software under the terms of the version
of the License under which You originally received the Covered Software,
or under the terms of any subsequent version published by the license
steward.
10.3. Modified Versions
If you create software not governed by this License, and you want to
create a new license for such software, you may create and use a
modified version of this License if you rename the license and remove
any references to the name of the license steward (except to note that
such modified license differs from this License).
10.4. Distributing Source Code Form that is Incompatible With Secondary
Licenses
If You choose to distribute Source Code Form that is Incompatible With
Secondary Licenses under the terms of this version of the License, the
notice described in Exhibit B of this License must be attached.
Exhibit A - Source Code Form License Notice
-------------------------------------------
This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to look
for such a notice.
You may add additional accurate notices of copyright ownership.
Exhibit B - "Incompatible With Secondary Licenses" Notice
---------------------------------------------------------
This Source Code Form is "Incompatible With Secondary Licenses", as
defined by the Mozilla Public License, v. 2.0.

View file

@ -0,0 +1,4 @@
pkg install golang
go build
mkdir -p $HOME/.local/bin
mv spow-whatsapp-bridge $HOME/.local/bin/s+ow-whatsapp-bridge

View file

@ -0,0 +1,22 @@
module spow-whatsapp-bridge
go 1.21.3
require (
github.com/mattn/go-sqlite3 v1.14.18
github.com/mdp/qrterminal/v3 v3.2.0
go.mau.fi/whatsmeow v0.0.0-20231104103606-23bd57d939ca
google.golang.org/protobuf v1.31.0
)
require (
filippo.io/edwards25519 v1.0.0 // indirect
github.com/gorilla/websocket v1.5.1 // indirect
go.mau.fi/libsignal v0.1.0 // indirect
go.mau.fi/util v0.2.0 // indirect
golang.org/x/crypto v0.14.0 // indirect
golang.org/x/net v0.17.0 // indirect
golang.org/x/sys v0.14.0 // indirect
golang.org/x/term v0.13.0 // indirect
rsc.io/qr v0.2.0 // indirect
)

View file

@ -0,0 +1,42 @@
filippo.io/edwards25519 v1.0.0 h1:0wAIcmJUqRdI8IJ/3eGi5/HwXZWPujYXXlkrQogz0Ek=
filippo.io/edwards25519 v1.0.0/go.mod h1:N1IkdkCkiLB6tki+MYJoSx2JTY9NUlxZE7eHn5EwJns=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.5.1 h1:gmztn0JnHVt9JZquRuzLw3g4wouNVzKL15iLr/zn/QY=
github.com/gorilla/websocket v1.5.1/go.mod h1:x3kM2JMyaluk02fnUJpQuwD2dCS5NDG2ZHL0uE0tcaY=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mattn/go-sqlite3 v1.14.18 h1:JL0eqdCOq6DJVNPSvArO/bIV9/P7fbGrV00LZHc+5aI=
github.com/mattn/go-sqlite3 v1.14.18/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdp/qrterminal v1.0.1/go.mod h1:Z33WhxQe9B6CdW37HaVqcRKzP+kByF3q/qLxOGe12xQ=
github.com/mdp/qrterminal/v3 v3.0.0 h1:ywQqLRBXWTktytQNDKFjhAvoGkLVN3J2tAFZ0kMd9xQ=
github.com/mdp/qrterminal/v3 v3.0.0/go.mod h1:NJpfAs7OAm77Dy8EkWrtE4aq+cE6McoLXlBqXQEwvE0=
github.com/mdp/qrterminal/v3 v3.2.0 h1:qteQMXO3oyTK4IHwj2mWsKYYRBOp1Pj2WRYFYYNTCdk=
github.com/mdp/qrterminal/v3 v3.2.0/go.mod h1:XGGuua4Lefrl7TLEsSONiD+UEjQXJZ4mPzF+gWYIJkk=
go.mau.fi/libsignal v0.1.0 h1:vAKI/nJ5tMhdzke4cTK1fb0idJzz1JuEIpmjprueC+c=
go.mau.fi/libsignal v0.1.0/go.mod h1:R8ovrTezxtUNzCQE5PH30StOQWWeBskBsWE55vMfY9I=
go.mau.fi/util v0.2.0 h1:AMGBEdg9Ya/smb/09dljo9wBwKr432EpfjDWF7aFQg0=
go.mau.fi/util v0.2.0/go.mod h1:AxuJUMCxpzgJ5eV9JbPWKRH8aAJJidxetNdUj7qcb84=
go.mau.fi/whatsmeow v0.0.0-20230805111647-405414b9b5c0 h1:6kAOyrp8E9p99X1I3uj7BtEFspdcVjnYzUZpqcHo/mE=
go.mau.fi/whatsmeow v0.0.0-20230805111647-405414b9b5c0/go.mod h1:+ObGpFE6cbbY4hKc1FmQH9MVfqaemmlXGXSnwDvCOyE=
go.mau.fi/whatsmeow v0.0.0-20231104103606-23bd57d939ca h1:r1/XGlSlYUFR4WTDKURPf8bTuPncrADZfOGPNrfr1oI=
go.mau.fi/whatsmeow v0.0.0-20231104103606-23bd57d939ca/go.mod h1:u557d2vph8xcLrk3CKTBknUHoB6icUpqazA4w+binRU=
golang.org/x/crypto v0.14.0 h1:wBqGXzWJW6m1XrIKlAH0Hs1JJ7+9KBwnIO8v66Q9cHc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/net v0.17.0 h1:pVaXccu2ozPjCXewfr1S7xza/zcXTity9cCdXQYSjIM=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.14.0 h1:Vz7Qs629MkJkGyHxUlRHizWJRG2j8fbQKjELVSNhy7Q=
golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.13.0 h1:bb+I9cTfFazGW51MZqBVmZy7+JEJMouUHTUSKVQLBek=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
rsc.io/qr v0.2.0 h1:6vBLea5/NRMVTz8V66gipeLycZMl/+UlFmk8DvqQ6WY=
rsc.io/qr v0.2.0/go.mod h1:IF+uZjkb9fqyeF/4tlBoynqmQxUoPfWEKh921coOuXs=

View file

@ -0,0 +1,385 @@
// Copyright (c) 2021 Tulir Asokan
// Copyright (c) 2023 Mark Joshwel
//
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"mime"
"os"
"os/signal"
"path"
"strings"
"sync/atomic"
"syscall"
"time"
_ "github.com/mattn/go-sqlite3"
"github.com/mdp/qrterminal/v3"
"google.golang.org/protobuf/proto"
// "go.mau.fi/libsignal/groups"
// "go.mau.fi/libsignal/keys/message"
"go.mau.fi/whatsmeow"
"go.mau.fi/whatsmeow/appstate"
waBinary "go.mau.fi/whatsmeow/binary"
waProto "go.mau.fi/whatsmeow/binary/proto"
"go.mau.fi/whatsmeow/store"
"go.mau.fi/whatsmeow/store/sqlstore"
"go.mau.fi/whatsmeow/types"
"go.mau.fi/whatsmeow/types/events"
waLog "go.mau.fi/whatsmeow/util/log"
)
var cli *whatsmeow.Client
var log waLog.Logger
var logLevel = "INFO"
var debugLogs = flag.Bool("debug", false, "Enable debug logs?")
var dbDialect = flag.String("db-dialect", "sqlite3", "Database dialect (sqlite3 or postgres)")
var dbAddress = flag.String("db-address", "file:mdtest.db?_foreign_keys=on", "Database address")
var requestFullSync = flag.Bool("request-full-sync", false, "Request full (1 year) history sync when logging in?")
var pairRejectChan = make(chan bool, 1)
var data_dir = path.Join(os.Getenv("HOME"), ".local", "share", "s+ow-whatsapp-bridge")
var sharetext_path = path.Join(os.Getenv("HOME"), ".cache", "s+ow", "message")
func main() {
if *debugLogs {
logLevel = "DEBUG"
}
log = waLog.Stdout("Main", logLevel, true)
// make and change dir
err := os.MkdirAll(data_dir, os.ModePerm)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to create directory: %v", err)
return
}
err = os.Chdir(data_dir)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to change directory: %v", err)
return
}
// mdtest code
waBinary.IndentXML = true
flag.Parse()
if *requestFullSync {
store.DeviceProps.RequireFullSync = proto.Bool(true)
}
dbLog := waLog.Stdout("Database", logLevel, true)
storeContainer, err := sqlstore.New(*dbDialect, *dbAddress, dbLog)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to connect to database: %v", err)
return
}
device, err := storeContainer.GetFirstDevice()
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to get device: %v", err)
return
}
cli = whatsmeow.NewClient(device, waLog.Stdout("Client", logLevel, true))
var isWaitingForPair atomic.Bool
cli.PrePairCallback = func(jid types.JID, platform, businessName string) bool {
isWaitingForPair.Store(true)
defer isWaitingForPair.Store(false)
log.Infof("s+ow-whatsapp-bridge: Pairing %s (platform: %q, business name: %q). Type r within 3 seconds to reject pair", jid, platform, businessName)
select {
case reject := <-pairRejectChan:
if reject {
log.Infof("s+ow-whatsapp-bridge: Rejecting pair")
return false
}
case <-time.After(3 * time.Second):
}
log.Infof("s+ow-whatsapp-bridge: Accepting pair")
return true
}
ch, err := cli.GetQRChannel(context.Background())
if err != nil {
// This error means that we're already logged in, so ignore it.
if !errors.Is(err, whatsmeow.ErrQRStoreContainsID) {
log.Errorf("s+ow-whatsapp-bridge: Failed to get QR channel: %v", err)
}
} else {
go func() {
for evt := range ch {
if evt.Event == "code" {
qrterminal.GenerateHalfBlock(evt.Code, qrterminal.L, os.Stdout)
} else {
log.Infof("s+ow-whatsapp-bridge: QR channel result: %s", evt.Event)
}
}
}()
}
cli.AddEventHandler(handler)
err = cli.Connect()
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to connect: %v", err)
return
}
c := make(chan os.Signal)
input := make(chan string)
signalChan := make(chan os.Signal, 1)
signal.Notify(signalChan, os.Interrupt, syscall.SIGTERM)
go func() {
defer close(input)
scan := bufio.NewScanner(os.Stdin)
for scan.Scan() {
line := strings.TrimSpace(scan.Text())
if len(line) > 0 {
input <- line
}
}
}()
// if 'login' in os.Args, we exit here
for _, arg := range os.Args {
if arg == "login" {
for {
select {
case <-c:
log.Infof("Interrupt received, exiting")
cli.Disconnect()
return
case cmd := <-input:
if len(cmd) == 0 {
log.Infof("Stdin closed, exiting")
cli.Disconnect()
return
}
}
}
}
}
// if using as cli
args := os.Args[1:]
if len(args) > 0 {
handleCmd(strings.ToLower(args[0]), args[1:])
return
}
// read file ~/.cache/s+ow/message
sharetext, err := os.ReadFile(sharetext_path)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to open file: %v", err)
return
}
// "normal" operation; read JID targets from stdin
targets := <-input
split := strings.Split(targets, ",")
for _, target := range split {
// strip whitespace
target = strings.TrimSpace(target)
// check if prefixed with "wa:"
if strings.HasPrefix(target, "wa:") {
// send message to group
recipient, ok := parseJID(target[3:])
if !ok {
return
}
msg := &waProto.Message{Conversation: proto.String(strings.TrimSpace(string(sharetext)))}
resp, err := cli.SendMessage(context.Background(), recipient, msg)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Error sending message: %v", err)
} else {
log.Infof("s+ow-whatsapp-bridge: Message sent (server timestamp: %s)", resp.Timestamp)
}
}
}
log.Infof("s+ow-whatsapp-bridge: Exiting")
cli.Disconnect()
return
}
func parseJID(arg string) (types.JID, bool) {
if arg[0] == '+' {
arg = arg[1:]
}
if !strings.ContainsRune(arg, '@') {
return types.NewJID(arg, types.DefaultUserServer), true
} else {
recipient, err := types.ParseJID(arg)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Invalid JID %s: %v", arg, err)
return recipient, false
} else if recipient.User == "" {
log.Errorf("s+ow-whatsapp-bridge: Invalid JID %s: no server specified", arg)
return recipient, false
}
return recipient, true
}
}
func handleCmd(cmd string, args []string) {
switch cmd {
case "logout":
err := cli.Logout()
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Error logging out: %v", err)
} else {
log.Infof("s+ow-whatsapp-bridge: Successfully logged out")
}
case "list":
groups, err := cli.GetJoinedGroups()
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to get group list: %v", err)
return
}
for _, group := range groups {
fmt.Printf("%s\t\t%s\n", group.JID, group.Name)
}
case "send":
if len(args) < 2 {
log.Errorf("s+ow-whatsapp-bridge: Usage: send <jid> <text>")
return
}
recipient, ok := parseJID(args[0])
if !ok {
return
}
msg := &waProto.Message{Conversation: proto.String(strings.Join(args[1:], " "))}
resp, err := cli.SendMessage(context.Background(), recipient, msg)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Error sending message: %v", err)
} else {
log.Infof("s+ow-whatsapp-bridge: Message sent (server timestamp: %s)", resp.Timestamp)
}
}
}
var historySyncID int32
var startupTime = time.Now().Unix()
func handler(rawEvt interface{}) {
switch evt := rawEvt.(type) {
case *events.AppStateSyncComplete:
if len(cli.Store.PushName) > 0 && evt.Name == appstate.WAPatchCriticalBlock {
err := cli.SendPresence(types.PresenceAvailable)
if err != nil {
log.Warnf("s+ow-whatsapp-bridge: Failed to send available presence: %v", err)
} else {
log.Infof("s+ow-whatsapp-bridge: Marked self as available")
}
}
case *events.Connected, *events.PushNameSetting:
if len(cli.Store.PushName) == 0 {
return
}
// Send presence available when connecting and when the pushname is changed.
// This makes sure that outgoing messages always have the right pushname.
err := cli.SendPresence(types.PresenceAvailable)
if err != nil {
log.Warnf("s+ow-whatsapp-bridge: Failed to send available presence: %v", err)
} else {
log.Infof("s+ow-whatsapp-bridge: Marked self as available")
}
case *events.StreamReplaced:
os.Exit(0)
case *events.Message:
metaParts := []string{fmt.Sprintf("pushname: %s", evt.Info.PushName), fmt.Sprintf("timestamp: %s", evt.Info.Timestamp)}
if evt.Info.Type != "" {
metaParts = append(metaParts, fmt.Sprintf("type: %s", evt.Info.Type))
}
if evt.Info.Category != "" {
metaParts = append(metaParts, fmt.Sprintf("category: %s", evt.Info.Category))
}
if evt.IsViewOnce {
metaParts = append(metaParts, "view once")
}
if evt.IsViewOnce {
metaParts = append(metaParts, "ephemeral")
}
if evt.IsViewOnceV2 {
metaParts = append(metaParts, "ephemeral (v2)")
}
if evt.IsDocumentWithCaption {
metaParts = append(metaParts, "document with caption")
}
if evt.IsEdit {
metaParts = append(metaParts, "edit")
}
log.Infof("s+ow-whatsapp-bridge: Received message %s from %s (%s): %+v", evt.Info.ID, evt.Info.SourceString(), strings.Join(metaParts, ", "), evt.Message)
if evt.Message.GetPollUpdateMessage() != nil {
decrypted, err := cli.DecryptPollVote(evt)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to decrypt vote: %v", err)
} else {
log.Infof("s+ow-whatsapp-bridge: Selected options in decrypted vote:")
for _, option := range decrypted.SelectedOptions {
log.Infof("s+ow-whatsapp-bridge: - %X", option)
}
}
} else if evt.Message.GetEncReactionMessage() != nil {
decrypted, err := cli.DecryptReaction(evt)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to decrypt encrypted reaction: %v", err)
} else {
log.Infof("s+ow-whatsapp-bridge: Decrypted reaction: %+v", decrypted)
}
}
img := evt.Message.GetImageMessage()
if img != nil {
data, err := cli.Download(img)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to download image: %v", err)
return
}
exts, _ := mime.ExtensionsByType(img.GetMimetype())
path := fmt.Sprintf("%s%s", evt.Info.ID, exts[0])
err = os.WriteFile(path, data, 0600)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to save image: %v", err)
return
}
log.Infof("s+ow-whatsapp-bridge: Saved image in message to %s", path)
}
case *events.HistorySync:
id := atomic.AddInt32(&historySyncID, 1)
fileName := fmt.Sprintf("history-%d-%d.json", startupTime, id)
file, err := os.OpenFile(fileName, os.O_WRONLY|os.O_CREATE, 0600)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to open file to write history sync: %v", err)
return
}
enc := json.NewEncoder(file)
enc.SetIndent("", " ")
err = enc.Encode(evt.Data)
if err != nil {
log.Errorf("s+ow-whatsapp-bridge: Failed to write history sync: %v", err)
return
}
log.Infof("s+ow-whatsapp-bridge: Wrote history sync to %s", fileName)
_ = file.Close()
case *events.AppState:
log.Debugf("App state event: %+v / %+v", evt.Index, evt.SyncActionValue)
case *events.KeepAliveTimeout:
log.Debugf("Keepalive timeout event: %+v", evt)
case *events.KeepAliveRestored:
log.Debugf("Keepalive restored")
}
}

View file

@ -0,0 +1,2 @@
SPOW_TELEGRAM_API_HASH="" SPOW_TELEGRAM_API_ID="" s+ow-telegram-bridge
s+ow-whatsapp-bridge

View file

@ -0,0 +1,284 @@
# surplus on wheels
surplus on wheels (s+ow) is a pure shell script to get your location using
`termux-location`, process it through [surplus](https://github.com/markjoshwel/surplus),
and send it to messaging service or wherever using [bridges](#bridges).
surplus was made to emulate sending your location through the iOS Shortcuts app, and
surplus on wheels complements it by running surplus automatically using a cron job.
(but using it manually also works!)
- [installing](#installing)
- [as a standalone script](#as-a-standalone-script)
- [as a cron job](#as-a-cron-job)
- [using installation scripts](#using-installation-scripts)
- [usage](#usage)
- [environment variables](#environment-variables)
- [faking locations](#faking-locations)
- [bridges](#bridges)
- [bring your own bridge](#bring-your-own-bridge)
- [licence](#licence)
## installing
> [!IMPORTANT]
> s+ow is a Termux-first script, and will not work anywhere else unless you have
> a utility that emulates [termux-location](https://wiki.termux.com/wiki/termux-location)
> on `$PATH` alongside bridges that supports your platform.
there are two notable ways to install s+ow:
1. [as a standalone script](#as-a-standalone-script)
2. or, [as a cron job](#as-a-cron-job).
there is also an [installation script](#using-installation-scripts) for quickly getting
started from a _fresh_ termux installation.
### as a standalone script
1. firstly install python and termux-api if you haven't already:
```text
pkg install python termux-api
```
also install the accompanying the Termux:API app from [F-Froid](https://f-droid.org/en/packages/com.termux.api/).
2. install pipx
```text
pip install pipx
```
3. install surplus:
```text
pip install https://github.com/markjoshwel/surplus/releases/latest/download/surplus-latest-py3-none-any.whl
```
4. install surplus on wheels:
```text
mkdir -p ~/.local/bin/
curl https://raw.githubusercontent.com/markjoshwel/surplus-on-wheels/main/s+ow > ~/.local/bin/s+ow
chmod +x ~/.local/bin/s+ow
```
if `~/.local/bin` is not in your `$PATH`, add the following to your shell's rc file:
```shell
export PATH="$HOME/.local/bin:$PATH"
```
et voilà! s+ow is now setup. to actually send the message to a messaging platform,
[install an appropriate bridge](#bridges).
### as a cron job
> [!IMPORTANT]
> these instructions rely on following the [previous instructions](#as-a-standalone-script).
1. install necessary packages to run cron jobs:
```text
pkg install cronie termux-services
```
2. restart termux and start the cron service:
```text
sv-enable cron
```
3. set up the cron job:
> [!IMPORTANT]
> minimally fill in the `SPOW_TARGETS` variable before running s+ow.
> [(see usage for more info)](#usage)
run the following command:
```text
crontab -e
```
and add the following text:
```text
59 * * * * bash -l -c "SPOW_TARGETS="" SPOW_CRON=y s+ow"
```
this will run s+ow every hour, a minute before the hour.
modify the variables as per your needs.
see [usage](#usage) for more information.
et voilà! s+ow will now send a message every hour. feel free to experiment with the cron
job to your liking. see [crontab.guru](https://crontab.guru/) if youre new to cron jobs.
if you havent already, [install an appropriate bridge](#bridges) to actually send a
message to a messaging platform.
### using installation scripts
> [!WARNING]
> these scripts assume you're starting from a fresh base installation of Termux.
> if you have already cron jobs, then manually carry out the instructiions in
> [as a cron job](#as-a-cron-job).
> [!IMPORTANT]
> if not installed already, install [Termux:API](https://f-droid.org/en/packages/com.termux.api/)
> from F-Droid.
1. setup s+ow:
```text
curl https://raw.githubusercontent.com/markjoshwel/surplus-on-wheels/main/termux-s+ow-setup | sh
```
2. restart termux!
3. setup cron job:
```text
curl https://raw.githubusercontent.com/markjoshwel/surplus-on-wheels/main/termux-s+ow-setup-cron | sh
```
the script will run `crontab -e`, and you can then edit the variables as per your
needs. minimally fill in the `SPOW_TARGETS` variable before running s+ow.
see [usage](#usage) for more information.
et voilà! s+ow is now setup. to actually send the message to a messaging platform,
[install an appropriate bridge](#bridges).
## usage
### environment variables
s+ow uses three environment variables, two of which are optional:
1. `SPOW_TARGETS`
a single line of comma-deliminated chat IDs with bridge prefixes.
```text
wa:000000000000000000@g.us,tg:-0000000000000000000,...
```
in the example above, the WhatsApp chat ID is `wa:`-prefixed as recognised by the
[spow-whatsapp-bridge](https://github.com/markjoshwel/spow-whatsapp-bridge), and the
Telegram chat ID is `tg:`-prefixed as recognised by the
[spow-telegram-bridge](https://github.com/markjoshwel/spow-telegram-bridge).
2. `SPOW_CRON` (optional)
set as non-empty to declare that s+ow is being run as a cron job.
if running as a cron job, start s+ow one minute earlier than intended to account for
the time it takes to run `termux-location` and `surplus`.
s+ow will delay itself appropriately.
setting it to `n` will also be treated as empty.
3. `LOCATION_PRIORITISE_NETWORK` (optional)
set as non-empty to declare that s+ow can just use network location instead of GPS
if GPS is taking too long.
you should only turn this on if punctuality means that much to you, or youre in a
country with cell towers close by or everywhere, like Singapore.
setting it to `n` will also be treated as empty.
the JIDs can be obtained by sending a message to the user/group, while running
`s+ow mdtest`, and examining the output for your message. JIDs are email address-like
strings.
4. `LOCATION_TIMEOUT` (optional)
set as a number to override the default first location timeout of `50`.
### faking locations
> sometimes you gotta do what you gotta do
you can fake your s+ow messages by either:
1. setting a dummy `last` file in s+ow cache
`$HOME/.cache/s+ow/last` is used as the fallback response when a part of s+ow (either
`termux-location` or `surplus` errors out). you can set this file to whatever you want
and just turn off location on your device.
2. setting a `fake` file in s+ow cache
> [!WARNING]
> s+ow uses the `read` command to read the file. as such, it is possible for s+ow to
> prematurely stop reading the file if the file does not contain a trailing newline.
you can also write text to `$HOME/.cache/s+ow/fake` to fake upcoming messages. the file
is delimited by empty lines. as such, arrange the file like so:
```text
The Clementi Mall
3155 Commonwealth Avenue West
Westpeak Terrace
129588
Southwest, Singapore
Westgate
3 Gateway Drive
Jurong East
608532
Southwest, Singapore
...
```
on every run of s+ow, the first group of lines will be consumed, and the file will be
updated with the remaining lines. if the file is empty, it will be deleted.
## bridges
there are two “official” bridges for s+ow:
- [spow-whatsapp-bridge](https://github.com/markjoshwel/spow-whatsapp-bridge)
- [spow-telegram-bridge](https://github.com/markjoshwel/spow-telegram-bridge)
bridges can be located anywhere, as long as they are reachable by the shell s+ow is
running in.
s+ow will run the bridges through definitions in in `$HOME/.s+ow-bridges`.
each line of `$HOME/.s+ow-bridges` is evaluated as a shell command, and is piped
`SPOW_TARGETS`.
> [!WARNING]
> s+ow uses the `read` command to read the file. as such, it is possible for s+ow to
> prematurely stop reading the file if the file does not contain a trailing newline.
### bring your own bridge
custom bridges are relatively easy as they are:
1. an executable or script
2. that reads `SPOW_TARGETS` (see [usage](#usage)) from stdin
- bridges should account for the possibility of comma and space (`, ` instead of just
`,`) delimited targets, and strip each target of preceding and trailing whitespace.
- bridges should recognise a platform based on a prefix
(e.g. `wa:` for WhatsApp, `tg:` for Telegram, etc.)
- bridges do not need to account for the possibility of multiple lines sent to stdin.
3. reads `SPOW_MESSAGE` (`~/.cache/spow/message`) for the message content
notes:
1. stderr and stdout are redirected to s+ows error and output logs respectively.
2. any errors encountered by the bridge should always result in a non-zero return.
error logs will show the exact error code, so feel free to use other numbers than 1.
3. persistent data such as credentials and session data storage are to be handled by the
bridge itself.
consider storing them in `$HOME/.local/share/<bridge-name>/`, or similar.
## licence
surplus on wheels is free and unencumbered software released into the public domain.
for more information, please refer to [UNLICENCE](/UNLICENCE) or <http://unlicense.org/>.

View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

483
src/surplus-on-wheels/s+ow Normal file
View file

@ -0,0 +1,483 @@
#!/bin/sh
# surplus on wheels (s+ow) - a pure shell script to run surplus with mdtest using the termux-api
# ------------------------
# public domain, unlicence
# shellcheck disable=SC2059
LOCATION_FALLBACK="%d%d%d\nSingapore?"
# shellcheck disable=SC2269
LOCATION_PRIORITISE_NETWORK="$LOCATION_PRIORITISE_NETWORK"
LOCATION_TIMEOUT=${LOCATION_TIMEOUT:-50}
# shellcheck disable=SC2269
SPOW_TARGETS="$SPOW_TARGETS"
SPOW_CACHE_DIR="$HOME/.cache/s+ow"
SPOW_BRIDGES="$HOME/.s+ow-bridges"
SPOW_CRON=${SPOW_CRON:-n}
# per-tool session logs
SPOW_NETLC_OUT="$SPOW_CACHE_DIR/location.net.json"
SPOW_GPSLC_OUT="$SPOW_CACHE_DIR/location.gps.json"
SPOW_LOCTN_OUT="$SPOW_CACHE_DIR/location.json"
SPOW_SPLUS_OUT="$SPOW_CACHE_DIR/surplus.out.log"
SPOW_SPLUS_ERR="$SPOW_CACHE_DIR/surplus.err.log"
# per-session collated logs
SPOW_SESH_OUT="$SPOW_CACHE_DIR/out.log"
SPOW_SESH_ERR="$SPOW_CACHE_DIR/err.log"
# per-week collated logs
SPOW_WEEK_PRE="$SPOW_CACHE_DIR/$(date +%Y)W$(date +"%V")"
SPOW_WEEK_OUT="$SPOW_WEEK_PRE.out.log"
SPOW_WEEK_ERR="$SPOW_WEEK_PRE.err.log"
# last successful surplus output
SPOW_LAST_OUT="$SPOW_CACHE_DIR/last"
# message to be sent
SPOW_MESSAGE="$SPOW_CACHE_DIR/message"
# list of fakes
SPOW_FAKE_OUT="$SPOW_CACHE_DIR/fake"
# check for network location priority
if [ "$LOCATION_PRIORITISE_NETWORK" = "n" ]; then
LOCATION_PRIORITISE_NETWORK=""
fi
# check for cron status
if [ "$SPOW_CRON" = "n" ]; then
SPOW_CRON=""
fi
# ensure commands exist
if ! command -v termux-location >/dev/null 2>&1; then
printf "s+ow: error: termux-location is not installed.\ninstall it with 'pkg install termux-api' and with installing the termux:api app from the play store or f-droid.\n"
exit 1
fi
if ! command -v surplus >/dev/null 2>&1; then
printf "s+ow: error: surplus is not installed.\ninstall it with 'pip install https://github.com/markjoshwel/surplus/releases/latest/download/surplus-latest-py3-none-any.whl'\n"
exit 1
fi
# ensure directories
mkdir -p "$SPOW_CACHE_DIR"
# create new session logs
rm -f "$SPOW_LOCTN_OUT" "$SPOW_SPLUS_OUT" "$SPOW_SPLUS_ERR" \
"$SPOW_SESH_OUT" "$SPOW_SESH_ERR"
touch "$SPOW_NETLC_OUT" "$SPOW_GPSLC_OUT" "$SPOW_LOCTN_OUT" \
"$SPOW_SPLUS_OUT" "$SPOW_SPLUS_ERR" \
"$SPOW_SESH_OUT" "$SPOW_SESH_ERR" \
"$SPOW_WEEK_OUT" "$SPOW_WEEK_ERR" \
"$SPOW_BRIDGES" "$SPOW_FAKE_OUT"
# 0 is nominal
# 1 is an termux-location error
# 2 is a surplus error
# 3 is a bridge/message send error
status=0
bridge_failures=0
bridge_returns=""
locate() {
# spawn termux-location processes
(
termux-location -p "network" >"$SPOW_NETLC_OUT"
if [ -s "$SPOW_NETLC_OUT" ]; then
printf "net" | tee -a "$SPOW_SESH_ERR"
else
printf "net?" | tee -a "$SPOW_SESH_ERR"
fi
cat "$SPOW_NETLC_OUT" >>"$SPOW_SESH_OUT"
) &
tl_net_pid="$!"
sleep 1
(
termux-location -p "gps" >"$SPOW_GPSLC_OUT"
if [ -s "$SPOW_GPSLC_OUT" ]; then
printf "gps" | tee -a "$SPOW_SESH_ERR"
else
printf "gps?" | tee -a "$SPOW_SESH_ERR"
fi
cat "$SPOW_GPSLC_OUT" >>"$SPOW_SESH_OUT"
) &
tl_gps_pid="$!"
# wait until timeout or both finished
printf "running termux-location" | tee -a "$SPOW_SESH_ERR"
while [ "$LOCATION_TIMEOUT" -gt 0 ]; do
# get process statuses
kill -0 "$tl_net_pid" >/dev/null 2>&1
tl_net_status="$?"
kill -0 "$tl_gps_pid" >/dev/null 2>&1
tl_gps_status="$?"
# break if both finished
if [ "$tl_net_status" -eq 1 ] && [ "$tl_gps_status" -eq 1 ]; then
break
fi
# exception: if network is proritised: just use that
if [ "$tl_net_status" -eq 1 ] && [ -n "$LOCATION_PRIORITISE_NETWORK" ]; then
# break only if theres an actual response
if [ -s "$SPOW_NETLC_OUT" ]; then
break
fi
# else just keep on waiting for gps to finish
fi
sleep 1
printf "." | tee -a "$SPOW_SESH_ERR"
LOCATION_TIMEOUT=$((LOCATION_TIMEOUT - 1))
done
if [ "$LOCATION_TIMEOUT" -eq 0 ]; then
printf " errored (timeout)\n" | tee -a "$SPOW_SESH_ERR"
else
printf " nominal\n" | tee -a "$SPOW_SESH_ERR"
fi
# check outputs
printf "determining output: " | tee -a "$SPOW_SESH_ERR"
if [ -s "$SPOW_NETLC_OUT" ] && [ -s "$SPOW_GPSLC_OUT" ]; then
printf "both succeeded, "
acc_net="$(grep "\"accuracy\"" <"$SPOW_NETLC_OUT" | awk -F ': ' '{print $2}' | tr -d ',')"
acc_gps="$(grep "\"accuracy\"" <"$SPOW_GPSLC_OUT" | awk -F ': ' '{print $2}' | tr -d ',')"
# compare accuracy
if awk -v n1="$acc_net" -v n2="$acc_gps" 'BEGIN { if (n1 < n2) exit 0; else exit 1; }'; then
printf "choosing network (%s < %s)" "$acc_net" "$acc_gps" | tee -a "$SPOW_SESH_ERR"
cat "$SPOW_NETLC_OUT" >"$SPOW_LOCTN_OUT"
else
printf "choosing gps (%s < %s)" "$acc_gps" "$acc_net" | tee -a "$SPOW_SESH_ERR"
cat "$SPOW_GPSLC_OUT" >"$SPOW_LOCTN_OUT"
fi
cat "$SPOW_GPSLC_OUT" >"$SPOW_LOCTN_OUT"
else
# one or none succeeded
if [ -s "$SPOW_NETLC_OUT" ]; then
if [ -n "$LOCATION_PRIORITISE_NETWORK" ]; then
printf "using network (prioritised)" | tee -a "$SPOW_SESH_ERR"
else
printf "using network" | tee -a "$SPOW_SESH_ERR"
fi
cat "$SPOW_NETLC_OUT" >"$SPOW_LOCTN_OUT"
fi
if [ -s "$SPOW_GPSLC_OUT" ]; then
printf "using gps" | tee -a "$SPOW_SESH_ERR"
cat "$SPOW_GPSLC_OUT" >"$SPOW_LOCTN_OUT"
fi
fi
if [ ! -s "$SPOW_LOCTN_OUT" ]; then
printf "none (error)" | tee -a "$SPOW_SESH_ERR"
fi
printf "\n" | tee -a "$SPOW_SESH_ERR"
}
gensharetext() {
surplus -td "$1" >"$SPOW_SPLUS_OUT" 2>"$SPOW_SPLUS_ERR"
ret="$?"
cat "$SPOW_SPLUS_OUT" >>"$SPOW_SESH_OUT"
cat "$SPOW_SPLUS_ERR" >>"$SPOW_SESH_ERR"
return "$ret"
}
send() {
# $1 is sharetext
# use fake if any
fake_first=""
fake_rest=""
if [ -n "$(cat "$SPOW_FAKE_OUT")" ]; then
inside_first_group=false
first_group_done=false
while IFS= read -r line; do
# skip preceding empty lines
if [ -z "$line" ] && [ "$inside_first_group" = false ] && [ -z "$fake_first" ]; then
continue
fi
# if not empty and not inside first group, then we are inside first group
if [ -n "$line" ] && [ "$inside_first_group" = false ] && [ "$first_group_done" = false ]; then
inside_first_group=true
# if empty and inside first group, then we are outside first group
elif [ -z "$line" ] && [ "$inside_first_group" = true ]; then
inside_first_group=false
first_group_done=true
# if empty and outside first group but first group is done, add newline to fake_rest
elif [ -z "$line" ] && [ "$inside_first_group" = false ] && [ "$first_group_done" = true ]; then
fake_rest="$fake_rest\n"
fi
# append to fake_first (if message_fake is not empty)
if [ "$inside_first_group" = true ]; then
if [ -z "$fake_first" ]; then
fake_first="$line"
else
fake_first="$(printf "%s\n%s" "$fake_first" "$line")"
fi
# append to fake_rest (if message_fake is not empty)
else
if [ -z "$fake_rest" ]; then
fake_rest="$line"
else
fake_rest="$(printf "%s\n%s" "$fake_rest" "$line")"
fi
fi
done <"$SPOW_FAKE_OUT"
if [ -n "$fake_first" ]; then
printf "$fake_rest\n" >"$SPOW_FAKE_OUT"
fi
fi
# choose what message to use
message=""
if [ -n "$fake_first" ]; then
echo using fake
message="$fake_first"
else
echo using message
message="$1"
fi
# store message
printf "%s\n" "$message" >"$SPOW_MESSAGE"
cat "$SPOW_MESSAGE" >>"$SPOW_SESH_OUT"
cat "$SPOW_MESSAGE" >>"$SPOW_SESH_ERR"
# run bridges
if [ -f "$SPOW_BRIDGES" ]; then
# run commands in bridge file
while IFS= read -r command; do
# skip command if its actually a comment
if [ "$(printf "%s" "$command" | head -c 1)" = "#" ]; then
continue
fi
# run bridge
echo "$command"
echo "$SPOW_TARGETS" | eval "$command" >>"$SPOW_SESH_ERR" 2>>"$SPOW_SESH_ERR"
bridge_return="$?"
# check if bridge failed
if [ ! "$bridge_return" -eq 0 ]; then
bridge_failures=$((bridge_failures + 1))
fi
# store return value
if [ -z "$bridge_returns" ]; then
bridge_returns="$bridge_return"
else
bridge_returns="$bridge_returns, $bridge_return"
fi
done <"$SPOW_BRIDGES"
else
printf "s+ow: warning: no '$SPOW_BRIDGES' file; message is not sent.\n"
termux-notification \
--priority "default" \
--title "surplus on wheels: No bridges" \
--content "No '$SPOW_BRIDGES' file; message is not sent."
fi
echo "$bridge_returns" >>"$SPOW_SESH_ERR"
}
notify_start() {
termux-notification \
--priority "min" \
--ongoing \
--id "s+ow" \
--title "surplus on wheels" \
--content "s+ow has started running."
}
notify() {
# $1 is text
# $2 is attempt number (if any)
attempt_text="$1"
if [ $# -eq 2 ]; then
attempt_text="$1 (attempt $2)"
fi
termux-notification \
--priority "min" \
--ongoing \
--id "s+ow" \
--title "surplus on wheels" \
--content "$attempt_text"
}
notify_end() {
# $1 is done tuple
# $2 is sharetext
termux-notification \
--priority "min" \
--id "s+ow" \
--title "surplus on wheels" \
--content "$(printf 'Run has finished. %s\n\n%s' "$1" "$2")"
}
# program functions
run() {
notify_start
printf "[run! stdout (%s)]\n" "$(date)" >>"$SPOW_SESH_OUT"
printf "[run! stderr (%s)]\n" "$(date)" >>"$SPOW_SESH_ERR"
time_run_start="$(date +%s)"
# if cron: wait until its the new hour
if [ -n "$SPOW_CRON" ]; then
notify "Waiting for the 30th second to pass..."
printf "waiting for the 30th second to pass...\n"
while [ "$(date +'%S')" -lt 30 ]; do
printf " $(date)\n"
sleep 1
done
printf "proceeding\n"
fi
time_locate_start="$(date +%s)"
# termux-location
location=""
for locate_run in 1 2 3; do # run three times in case :p
notify "Running termux-location" "$locate_run"
if [ "$locate_run" -gt "1" ]; then
LOCATION_TIMEOUT=75 locate
else
locate
fi
if [ ! -s "$SPOW_LOCTN_OUT" ]; then
# erroneous: is empty
echo "s+ow: error: failed to get location" | tee -a "$SPOW_SESH_ERR"
status=1
else
# nominal: is not empty
location="$(cat "$SPOW_LOCTN_OUT")"
status=0
break
fi
done
time_locate_end="$(date +%s)"
time_locate=$((time_locate_end - time_locate_start))
time_surplus_start="$(date +%s)"
# surplus
printf "running surplus... "
notify "Running surplus -td $location"
if [ "$status" -eq 0 ]; then
if gensharetext "$location"; then
# surplus ran nominally
cp "$SPOW_SPLUS_OUT" "$SPOW_LAST_OUT"
status=0
printf "nominal\n"
else
# something happened :^)
status=2
printf "errored\n"
fi
else
printf "skipped\n"
fi
time_surplus_end="$(date +%s)"
time_surplus=$((time_surplus_end - time_surplus_start))
# if cron: wait until its the new hour
if [ -n "$SPOW_CRON" ]; then
notify "Waiting for the new hour..."
printf "waiting until the new hour...\n"
while [ "$(date +'%M')" -eq 59 ]; do
printf " $(date)\n"
sleep 1
done
printf "proceeding\n"
fi
time_sendmsg_start="$(date +%s)"
# mdtest/send message
printf "sending message(s)... "
notify "Sending message(s)"
sent_type=0 # 0 for freshly made sharetext
# 1 for recycling a last location
# 2 for using fallback template
bridge_failures=0
sharetext=""
send_error_notif=false
if [ "$status" -eq 0 ]; then
# s+ow has behaved nominally until now, send as per normal
sharetext="$(cat "$SPOW_SPLUS_OUT")"
printf "\n"
send "$sharetext"
else
# something has gone wrong, send an appropriate fallback
sharetext=""
if [ -s "$SPOW_LAST_OUT" ]; then
# use last successful location
sharetext="$(cat "$SPOW_LAST_OUT")"
sent_type=1
printf "using last...\n"
else
# no last location, use fallback
# shellcheck disable=SC2059
sharetext="$(printf "$LOCATION_FALLBACK" "$status" "$locate_run" "$sent_type")"
sent_type=2
printf "using fallback... \n"
fi
send "$sharetext"
# send done info except
printf "(%d, %d, %d, %s<%s>) [lc:%ds sp:%ds]\n" "$status" "$locate_run" "$sent_type" "$bridge_failures" "$bridge_returns" "$time_locate" "$time_surplus" >>"$SPOW_SESH_ERR"
send_error_notif=true
fi
time_sendmsg_end="$(date +%s)"
time_sendmsg=$((time_sendmsg_end - time_sendmsg_start))
time_run_end="$(date +%s)"
time_run=$((time_run_end - time_run_start))
done_info="$(printf "(%d, %d, %d, %s<%s>) [lc:%ds sp:%ds sm:%ds - %ds]\n" "$status" "$locate_run" "$sent_type" "$bridge_failures" "$bridge_returns" "$time_locate" "$time_surplus" "$time_sendmsg" "$time_run")"
echo "done $done_info" | tee -a "$SPOW_SESH_ERR"
# finish
notify_end "$done_info" "$sharetext"
if [ ! "$send_error_notif" = false ]; then
# error notification
termux-notification \
--priority "default" \
--title "surplus on wheels has errored" \
--content "$(printf "\n(%d, %d, %d, %s<%s>)\n[lc:%ds sp:%ds sm:%ds - %ds]\n" "$status" "$locate_run" "$sent_type" "$bridge_failures" "$bridge_returns" "$time_locate" "$time_surplus" "$time_sendmsg" "$time_run")"
fi
}
# script entry
if [ -z "$SPOW_TARGETS" ]; then
echo "s+ow: error: SPOW_TARGETS are not set"
exit 1
fi
run
printf "%s\n\n" "$(cat "$SPOW_SESH_OUT")" >>"$SPOW_WEEK_OUT"
printf "%s\n\n" "$(cat "$SPOW_SESH_ERR")" >>"$SPOW_WEEK_ERR"

View file

@ -0,0 +1,22 @@
#!/bin/sh
set -e
# get packages
yes | pkg upgrade
yes | pkg install python cronie termux-api termux-services wget
# install pipx
pip install pipx
# install surplus
pipx install https://github.com/markjoshwel/surplus/releases/latest/download/surplus-latest-py3-none-any.whl
# install s+ow
mkdir -p ~/.local/bin/
curl https://raw.githubusercontent.com/markjoshwel/surplus-on-wheels/main/s+ow > ~/.local/bin/s+ow
chmod +x ~/.local/bin/s+ow
# setup path
echo "export PATH=\$PATH:\$HOME/.local/bin/" >> ~/.profile
printf "\ndone\n"

View file

@ -0,0 +1,12 @@
#!/bin/sh
# enable cron service and add to crontab
sv-enable crond
printf "59 * * * *\tSPOW_TARGETS=\"\" SPOW_CRON=y ~/.local/bin/s+ow\n" > s+ow.cron
crontab s+ow.cron
rm s+ow.cron
# open editor
crontab -e
printf "\ndone\n"

24
src/surplus/UNLICENCE Normal file
View file

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <http://unlicense.org/>

View file

@ -69,16 +69,16 @@ if TYPE_CHECKING:
# constants # constants
__version__ = "2024.0.0-alpha" __version__ = "2024.0.0-beta"
VERSION: Final[tuple[int, int, int]] = (2024, 0, 0) VERSION: Final[tuple[int, int, int]] = (2024, 0, 0)
VERSION_SUFFIX: Final[str] = "-local" VERSION_SUFFIX: Final[str] = "-beta-local"
BUILD_BRANCH: Final[str] = "future" BUILD_BRANCH: Final[str] = "future"
BUILD_COMMIT: Final[str] = "latest" BUILD_COMMIT: Final[str] = "latest"
BUILD_DATETIME: Final[datetime] = datetime.now(timezone(timedelta(hours=8))) # using SGT BUILD_DATETIME: Final[datetime] = datetime.now(timezone(timedelta(hours=8))) # using SGT
CONNECTION_MAX_RETRIES: int = 9 CONNECTION_MAX_RETRIES: int = 9
CONNECTION_WAIT_SECONDS: int = 10 CONNECTION_WAIT_SECONDS: int = 10
LOCALITY_GEOCODER_LEVEL: int = 13 # adjusts geocoder zoom level when LOCALITY_GEOCODER_LEVEL: int = 13 # adjusts geocoder zoom level when
# geocoding latlong into an address # geocoding lat long into an address
# default shareable text line keys # default shareable text line keys
SHAREABLE_TEXT_LINE_0_KEYS: dict[str, tuple[str, ...]] = { SHAREABLE_TEXT_LINE_0_KEYS: dict[str, tuple[str, ...]] = {