Metadata-Version: 2.4
Name: groupme-push
Version: 0.0.5
Summary: GroupMe push service wrapper
Author-email: Chris Cush <chris@cuuush.com>
License: GPL-3.0-or-later
Project-URL: Homepage, https://github.com/cuuush/groupme-push
Project-URL: Issues, https://github.com/cuuush/groupme-push/issues
Project-URL: Changelog, https://github.com/cuuush/groupme-push/blob/main/CHANGELOG.md
Keywords: groupme,push,websocket,faye
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Communications :: Chat
Requires-Python: >=3.9
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: base36>=0.1.1
Requires-Dist: requests>=2.31.0
Requires-Dist: websocket-client>=1.7.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == "dev"
Dynamic: license-file

# GroupMe Push Client

A client for the [GroupMe push service](https://dev.groupme.com/tutorials/push) (Faye). It opens a websocket to GroupMe and calls your functions when messages, DMs and likes arrive.

## Installation

`pip install groupme-push`, or clone the repo and run `pip install .`

## Quick start

```python
from groupme_push import PushClient

def on_message(message):
    print(message["text"])

client = PushClient(access_token="useraccesstoken", on_message=on_message)
client.start()
client.join()  # block until the client stops
```

Get an access token from [dev.groupme.com](https://dev.groupme.com/).

## Listening to one group

**GroupMe pushes every group's messages down your personal channel**, so `subscribe_to_group()` does not narrow the stream — it adds a group's own channel (typing notifications and the like) on top. To hear about only certain groups, pass `group_ids`:

```python
client = PushClient(
    access_token="useraccesstoken",
    on_message=on_message,
    group_ids=["12345678"],  # everything else is dropped
)
client.start()
```

DMs and other events that have no group id are never filtered out; leave `on_dm` unset if you do not want them.

To find your group ids:

```python
for group_id, name in PushClient(access_token="useraccesstoken").list_groups():
    print(group_id, name)
```

## Options

| Argument | Description |
| --- | --- |
| `access_token` | GroupMe access token for the user you want to listen as. |
| `on_message` | Called with the message subject for every group message. |
| `on_dm` | Called for direct messages. |
| `on_like` | Called when another user likes a message. |
| `on_favorite` | Called when your user likes a message. |
| `on_other` | Called with the raw `data` blob for anything else, such as poll results. |
| `on_connect` | Called with no arguments each time the socket becomes ready, including after a reconnect. |
| `on_error` | Called with the exception when the websocket errors. |
| `disregard_self` | Skip events sent by the authenticated user. Default `False`. |
| `reconnect` | Seconds to wait before reconnecting after a dropped connection. Default `5`. `None` disables reconnection, which means any drop is permanent. |
| `ping_interval` / `ping_timeout` | Websocket keepalive. Defaults `30` / `10`. See below. |
| `stall_timeout` | Seconds of silence after which the client checks that the stream is still alive. Default `180`. `None` disables the check. |
| `probe_timeout` | How long that check waits for an answer. Default `10`. |
| `group_ids` | Only dispatch events from these groups. Default: no filtering. |
| `subscribe_to_user_channel` | Subscribe to `/user/<id>` on connect. This is where GroupMe delivers messages, so leave it on unless you know otherwise. Default `True`. |
| `threaded_callbacks` | Run each callback in its own thread. Set `False` to run them inline, which keeps events in order but blocks the socket while they run. Default `True`. |
| `request_timeout` | Timeout in seconds for the HTTP calls `start()` makes. Default `5`. |

## Staying connected

A GroupMe push connection does not stay up on its own. An idle one is closed by the server, and the Faye session behind it gets reaped, after which the socket can still look perfectly healthy while never delivering anything again. That is the "it ran fine all day and then just stopped receiving" failure.

The client handles this out of the box:

- **Websocket pings** (`ping_interval`) keep the connection warm and, more importantly, make a dead connection *raise* instead of hanging. Without them the reader blocks on a socket that will never produce another byte, which is what a sleeping laptop or a NAT timeout leaves behind.
- **Automatic reconnection** is on by default (`reconnect=5`). Subscriptions are replayed on the new connection.
- **A liveness check** (`stall_timeout`). GroupMe sends nothing at all down an idle connection, so silence proves nothing on its own. After a quiet stretch the client asks a question instead of guessing: it re-subscribes to a channel it already holds, which is the one request GroupMe reliably answers. An answer means the stream is fine, an error means the session was reaped and it re-handshakes, and no answer at all means the socket is gone and it reconnects.

If you would rather do your own supervision, set `reconnect=None` and `stall_timeout=None`.

## Methods

- `start(wait=False, timeout=10)` — authenticate, handshake and open the socket. With `wait=True` it blocks until the socket is open and raises `ConnectionTimeout` if it never does.
- `stop(timeout=5)` — close the socket and stop reconnecting.
- `join(timeout=None)` — block the calling thread until the client stops.
- `wait_until_connected(timeout=10)` / `is_connected` — connection state.
- `subscribe_to_group(group_id, timeout=10)` — subscribe to a group's own channel. Safe to call right after `start()`: it waits for the socket, and the subscription is replayed automatically after a reconnect.
- `unsubscribe_from_group(group_id)` — the reverse.
- `list_groups(per_page=100)` — `[(group_id, name), ...]` for the user's groups, over REST. Handy for finding the ids to pass to `group_ids`.

`PushClient` is also a context manager:

```python
with PushClient(access_token="useraccesstoken", on_message=on_message) as client:
    client.join()
```

## Registering handlers with decorators

```python
client = PushClient(access_token="useraccesstoken")

@client.receive_message
def on_message(message):
    print(message["text"])
```

There is one of these per callback: `receive_message`, `receive_dm`, `receive_like`, `receive_favorite`, `receive_other`, `receive_connect`, `receive_error`.

## Errors

`start()` raises instead of logging and carrying on with a dead client:

- `AuthenticationError` — GroupMe rejected the access token.
- `HandshakeError` — Faye did not return a client id.
- `ConnectionTimeout` — `start(wait=True)` timed out.

All of them subclass `GroupMePushError`.

## Examples

See [`examples/`](examples/): [`basic.py`](examples/basic.py), [`single_group.py`](examples/single_group.py), [`decorators.py`](examples/decorators.py).

## Development

```sh
pip install -e ".[dev]"
pytest
```

The tests run against a scripted fake websocket, so no token or network access is needed.

Releases are additionally smoke tested against the live GroupMe service: the client creates a throwaway group, posts to it, and asserts on delivery, ordering, filtering, `disregard_self` and reconnect recovery.

## Issues

If you encounter any bugs or have feature requests, [please open an issue on GitHub](https://github.com/cuuush/groupme-push/issues).
