Compare commits

...

21 Commits

Author SHA1 Message Date
829539bdb8 Merge pull request 'feat: hourly rate limits for triggers' (#8) from feat/hourly-ratelimits into main
All checks were successful
Lint and Syntax Check / build (push) Successful in 5s
Reviewed-on: #8
2026-04-04 17:25:03 +02:00
80510370b0 docs: add AGENT_HOURLY env variable to README
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 12s
2026-04-04 17:23:00 +02:00
102c146b60 Delete __pycache__/main.cpython-313.pyc
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 12s
2026-04-04 17:20:01 +02:00
6ee9d2429c Add __pycache__/ to .gitignore and remove cached pyc file
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 5s
2026-04-04 17:19:16 +02:00
6532bd250d feat: add hourly rate limits for agent triggers
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 6s
- Add AGENT_HOURLY env var (default: 60 triggers per hour)
- Track hourly usage in SHSF database with key 'agent_{AGENT_USERNAME}_cooldown'
- Check and increment counter before sending notifications
- Add 'clear_limit' route to reset counter hourly via external trigger

Resolves #4
2026-04-04 17:10:23 +02:00
54840def97 Merge pull request 'feat: Custom Thinking effort' (#7) from feat/custom-thinking-effort into main
All checks were successful
Lint and Syntax Check / build (push) Successful in 12s
Reviewed-on: #7
2026-04-04 16:57:48 +02:00
5d679421de feat: add support for custom thinking effort (resolves #6)
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 1m45s
2026-04-04 16:27:45 +02:00
Space-Banane
eb42400e5b removed timeout
Some checks failed
Lint and Syntax Check / build (push) Failing after 11s
2026-04-04 16:24:36 +02:00
Space-Banane
73335050aa fix: improve message handling and formatting in build_message function
All checks were successful
Lint and Syntax Check / build (push) Successful in 11s
2026-04-04 16:17:58 +02:00
Space-Banane
cd79af6599 Dump json object
All checks were successful
Lint and Syntax Check / build (push) Successful in 11s
2026-04-04 15:21:10 +02:00
Space-Banane
972979a44d removed sample prompts
All checks were successful
Lint and Syntax Check / build (push) Successful in 11s
2026-04-04 14:57:04 +02:00
Space-Banane
990be58fc0 feat: add dispatched Gitea action prompt template
All checks were successful
Lint and Syntax Check / build (push) Successful in 6s
2026-04-04 14:26:27 +02:00
38a5a29bb6 Merge pull request 'feat: custom prompts & notify on comments' (#3) from feat/custom-prompts into main
All checks were successful
Lint and Syntax Check / build (push) Successful in 12s
2026-04-04 00:55:35 +02:00
Space-Banane
61896a2139 feat: enhance agent notifications for issue comments and assignments
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 5s
2026-04-04 00:15:29 +02:00
Space-Banane
31d049d211 feat: temporarily disable sending events to agent for unassign actions
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 33s
2026-04-03 23:46:36 +02:00
Space-Banane
ee43fbf4b1 feat: add prompt.txt to .gitignore
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 1m10s
2026-04-03 22:53:14 +02:00
Space-Banane
97813eb5bc feat: add .shsf.json to .gitignore
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 5s
2026-04-03 22:02:10 +02:00
Space-Banane
cd0f7c85ed feat: add support for custom notification templates via AGENT_PROMPT_FILE_PATH
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 7s
2026-04-03 22:00:44 +02:00
4144e122fa Merge pull request 'feat: make OpenClaw model configurable' (#2) from fix-hardcoded-model into main
All checks were successful
Lint and Syntax Check / build (push) Successful in 41s
Reviewed-on: #2
2026-04-02 17:32:33 +02:00
69f7f3b206 refactor: enforce OPENCLAW_MODEL as a required environment variable
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 5s
2026-04-02 17:17:57 +02:00
e9c540b8c1 feat: make OpenClaw model configurable via environment variable
All checks were successful
Lint and Syntax Check / build (pull_request) Successful in 5s
2026-04-02 17:17:01 +02:00
3 changed files with 227 additions and 48 deletions

3
.gitignore vendored Normal file
View File

@@ -0,0 +1,3 @@
.shsf.json
prompt.txt
__pycache__/

View File

@@ -1,13 +1,14 @@
# Assign Me Openclaw (<a href="https://github.com/Space-Banane/shsf"><img src="https://github.com/Space-Banane/Space-Banane/blob/main/WORKS%20WITH%20SHSF%20-%20WHITE%20TEXT.png?raw=true" height="30" alt="Works with SHSF" style="vertical-align: middle;"></a>)
This project is a serverless function (designed for SHSF) that handles Gitea webhooks for issue and pull request assignments. It notifies an AI agent (via OpenClaw) when they are assigned or unassigned, even if the unassignment event lacks assignee data. The function uses a database to track assignees across events, ensuring accurate notifications.
This project is a serverless function (designed for SHSF) that handles Gitea webhooks for issue and pull request assignments and comments. It notifies an AI agent (via OpenClaw) when they are assigned, unassigned, or mentioned in a comment. The function uses a database to track assignees across events, ensuring accurate notifications.
## Features
- **Multiple Assignee Support**: Correctly handles Gitea events with multiple assignees.
- **State Persistence**: Uses a database to track assignees across events, ensuring "unassigned" events have the necessary context even when the payload is sparse.
- **AI Integration**: Sends formatted Markdown notifications to an OpenClaw-compatible API.
- **Assignment Logic**: Specifically identifies when the configured `AGENT_USERNAME` is involved in an assignment change.
- **Assignment & Mention Logic**: Specifically identifies when the configured `AGENT_USERNAME` is involved in an assignment change or mentioned in a comment (e.g., `@youragent`).
- **Comment Notifications**: Notifies the agent about new comments on issues or PRs they are assigned to, or when they are tagged.
## Environment Variables
@@ -18,17 +19,30 @@ The following environment variables are required for the service to function:
| `OPENCLAW_URL` | The endpoint URL for the OpenClaw API. (with hook path) |
| `OPENCLAW_TOKEN` | The authentication token for OpenClaw. (from hooks, not gateway ui) |
| `OPENCLAW_PROXY_AUTH` | (Optional) Proxy credentials in `username:password` format. |
| `OPENCLAW_THINKING` | (Optional) The thinking effort for the agent (e.g., `low`, `medium`, `high`). Defaults to `low`. |
| `DATABASE_STORAGE_NAME` | The name of the database storage to use. |
| `AGENT_USERNAME` | The Gitea username of the AI agent (e.g., `whateveryouragentisnamed`). |
| `AGENT_PROMPT_FILE` | (Optional) Path to a custom Markdown template for notifications. If not provided, a default template will be used. |
| `AGENT_HOURLY` | (Optional) Maximum number of triggers allowed per hour. Defaults to `60`. Used for rate limiting. |
## How it works
1. **Webhook Reception**: The `main(args)` function receives a POST request from Gitea.
2. **Event Filtering**: It filters for `issues` and `pull_request` events with `assigned` or `unassigned` actions.
3. **Database Lookups**: If an `unassigned` event lacks assignee data, the script retrieves the last known assignees from the database.
4. **Agent Notification**: If the configured `AGENT_USERNAME` is found in the assignee list, a detailed Markdown message is constructed and sent to OpenClaw.
2. **Event Filtering**: It filters for `issues`, `pull_request`, and `issue_comment` events.
3. **Database Lookups**: If an `unassigned` or `issue_comment` event lacks assignee data in the payload, the script retrieves the last known assignees from the database.
4. **Agent Notification**: If the configured `AGENT_USERNAME` is found in the assignee list or is mentioned (case-insensitive) in a comment, a detailed Markdown message is constructed and sent to OpenClaw.
5. **Database Sync**: The current state of assignees for the specific issue/PR is updated in the database for future reference.
## Webhook Pointers
Ensure you Webhook is pointed from YOUR and YOUR AGENTS Gitea account to the Function, or have an Gitea Wide Webhook pointed to your Function, the code will filter events that don't belong to your agent.
### Webhook Event Checklist
To use all features, ensure your Gitea webhook is configured to send:
- Issues (Assignment changes)
- Pull Request (Assignment changes)
- Issue Comment (Created) - *Note: Gitea sends both Issue and PR comments as `issue_comment` events.*
- Pull Request Comment (Created) - *Note: Handled as `issue_comment` events in Gitea.*
## Development
The project consists of:

248
main.py
View File

@@ -6,15 +6,34 @@ from _db_com import database
OPENCLAW_URL = os.getenv("OPENCLAW_URL")
OPENCLAW_TOKEN = os.getenv("OPENCLAW_TOKEN")
OPENCLAW_PROXY_AUTH = os.getenv("OPENCLAW_PROXY_AUTH")
OPENCLAW_MODEL = os.getenv("OPENCLAW_MODEL")
OPENCLAW_THINKING = os.getenv("OPENCLAW_THINKING", "low")
DATABASE_STORAGE = os.getenv("DATABASE_STORAGE_NAME")
AGENT_USERNAME = os.getenv("AGENT_USERNAME")
AGENT_PROMPT_FILE = os.getenv("AGENT_PROMPT_FILE")
AGENT_HOURLY = int(os.getenv("AGENT_HOURLY", "60"))
eventsToHandle = ["pull_request", "issues"]
actionsToHandle = ["assigned", "unassigned"]
eventsToHandle = ["pull_request", "issues", "issue_comment"]
actionsToHandle = ["assigned", "created"]
# Template Fields:
DEFAULT_PROMPT_TEMPLATE = """# GITEA STATUS UPDATE
- You were [action_str] to an [type_str] in [repo_name] by [sender].
raw event data:
```json
[stringified_event]
```
Please check the details and take necessary actions.
DM your human in the main session and tell them about this change and if you should do anything about it.
Inform them that you have to be told the exact details on what to do next.
You won't know what you sent them once they respond, which is a restriction of your system.
The Goal is to solve this issue or PR as quickly and with as little back and forth as possible, so try to get all necessary information in the first message."""
def get_assignee_key(event_type, repository_id, number):
if event_type == "issues" or event_type == "issue":
if event_type == "issues" or event_type == "issue" or event_type == "issue_comment":
return f"assignees_issue_{repository_id}_{number}"
elif event_type == "pull_request":
return f"assignees_pr_{repository_id}_{number}"
@@ -58,22 +77,128 @@ def delete_stored_assignees(db, event_type, repository_id, number):
print(f"Failed to delete assignees from DB: {e}")
def sendToAgent(event_object):
def get_rate_limit_key():
return f"agent_{AGENT_USERNAME}_cooldown"
def check_and_increment_rate_limit(db):
"""Check if rate limit is reached, increment counter if not. Returns True if allowed, False if limit reached."""
key = get_rate_limit_key()
try:
current = db.get(DATABASE_STORAGE, key)
if current is None:
current = 0
else:
current = int(current)
if current >= AGENT_HOURLY:
print(f"Rate limit reached: {current}/{AGENT_HOURLY} hourly triggers used.")
return False
db.set(DATABASE_STORAGE, key, str(current + 1))
print(f"Rate limit check passed: {current + 1}/{AGENT_HOURLY} hourly triggers used.")
return True
except Exception as e:
print(f"Failed to check/increment rate limit: {e}")
# On error, allow the request to proceed
return True
def reset_rate_limit(db):
"""Reset the hourly rate limit counter."""
key = get_rate_limit_key()
try:
db.set(DATABASE_STORAGE, key, "0")
print(f"Rate limit reset for agent {AGENT_USERNAME}.")
except Exception as e:
print(f"Failed to reset rate limit: {e}")
def fill_template(template, event_object, action_str, type_str):
fields = {
"action_str": action_str,
"type_str": type_str,
"repo_name": event_object["repository"],
"sender": event_object["sender"],
"stringified_event": json.dumps(event_object, indent=2),
}
for key, value in fields.items():
template = template.replace(f"[{key}]", value)
return template
def build_message(event_object, action_str, type_str):
if type_str == "comment":
action_summary = f"There is a new comment on a {event_object['target_type']} you are assigned to in {event_object['repository']} by {event_object['sender']}."
else:
action_summary = f"You were {action_str} to an {type_str} in {event_object['repository']} by {event_object['sender']}."
if AGENT_PROMPT_FILE:
AGENT_PROMPT_FILE_PATH = "/app/" + AGENT_PROMPT_FILE
# Check if the file exists
if not os.path.isfile(AGENT_PROMPT_FILE_PATH):
print(
f"Custom prompt file not found at {AGENT_PROMPT_FILE_PATH}. Using default message."
)
message = fill_template(
DEFAULT_PROMPT_TEMPLATE, event_object, action_str, type_str
)
message = message.replace(
"You were [action_str] to an [type_str]", action_summary
)
return message
with open(AGENT_PROMPT_FILE_PATH, "r") as f:
custom_prompt = f.read()
message = fill_template(custom_prompt, event_object, action_str, type_str)
message = message.replace(
"You were [action_str] to an [type_str]", action_summary
)
return message
else:
print("No custom prompt file specified. Using default message.")
message = fill_template(
DEFAULT_PROMPT_TEMPLATE, event_object, action_str, type_str
)
message = message.replace(
"You were [action_str] to an [type_str]", action_summary
)
return message
def sendToAgent(event_object, db):
headers = {"x-openclaw-token": OPENCLAW_TOKEN, "Content-Type": "application/json"}
action_str = "assigned" if event_object["action"] == "assigned" else "unassigned"
type_str = "issue" if event_object["type"] == "issue" else "pull request"
print(f"Preparing to send notification to Agent for {json.dumps(event_object)}")
assignees = event_object.get("assignees", [event_object.get("assignee", "Unknown")])
if event_object.get("type") == "issue_comment":
action_str = "created"
type_str = "comment"
mention = f"@{AGENT_USERNAME}".lower()
comment_body = event_object.get("comment_body", "").lower()
if mention not in comment_body:
print(
f"Agent {AGENT_USERNAME} was not mentioned in comment body. Skipping notification."
)
return
else:
action_str = "assigned"
type_str = "issue" if event_object["type"] == "issue" else "pull request"
assignees = event_object.get(
"assignees", [event_object.get("assignee", "Unknown")]
)
if AGENT_USERNAME not in assignees:
print(
f"Agent {AGENT_USERNAME} is not among the assignees for this event. Skipping notification."
)
return
if AGENT_USERNAME in assignees:
message = f"""# GITEA STATUS UPDATE
- You were {action_str} to an {type_str} in {event_object['repository']} by {event_object['sender']}.
raw event data:
```json{json.dumps(event_object, indent=2)}```
Please check the details and take necessary actions.
# Check rate limit before sending
if not check_and_increment_rate_limit(db):
print("Rate limit reached. Skipping notification to agent.")
return
DM your human in the main session and tell them about this change and if you should do anything about it.
Inform them that you have to be told the exact details on what to do next, as you won't know what you sent them once they respond, which is a restriction of your system."""
message = build_message(event_object, action_str, type_str)
try:
if OPENCLAW_PROXY_AUTH:
@@ -81,7 +206,15 @@ Inform them that you have to be told the exact details on what to do next, as yo
else:
auth = None
response = requests.post(
OPENCLAW_URL, headers=headers, json={"message": message, "thinking": "low", "timeoutSeconds": 45, "model": "openrouter/google/gemini-3-flash-preview"}, auth=auth
OPENCLAW_URL,
headers=headers,
json={
"message": message,
"thinking": OPENCLAW_THINKING,
"model": OPENCLAW_MODEL,
"deliver": False,
},
auth=auth,
)
if response.status_code == 200:
print(
@@ -97,7 +230,13 @@ Inform them that you have to be told the exact details on what to do next, as yo
# SHSF Handler - Serverless
def main(args):
required_data = ["OPENCLAW_URL", "OPENCLAW_TOKEN", "DATABASE_STORAGE_NAME", "AGENT_USERNAME"]
required_data = [
"OPENCLAW_URL",
"OPENCLAW_TOKEN",
"DATABASE_STORAGE_NAME",
"AGENT_USERNAME",
"OPENCLAW_MODEL",
]
for var in required_data:
if not os.getenv(var):
return {
@@ -106,7 +245,7 @@ def main(args):
"_res": {"error": f"Missing required environment variable: {var}"},
"_headers": {"Content-Type": "application/json"},
}
headers = args.get("headers", {})
route = args.get("route")
event_type = headers.get("X-Gitea-Event") or headers.get("x-gitea-event")
@@ -175,7 +314,7 @@ def main(args):
"_headers": {"Content-Type": "application/json"},
}
if action in ["assigned", "unassigned"]:
if action == "assigned" and event_type in ["issues", "pull_request"]:
assignee_data = data.get("assignee")
assignees_data = data.get("assignees")
@@ -251,36 +390,41 @@ def main(args):
}
if event_object:
# Use helpers to store/delete assignees
# Store assignees for later comment events.
if action == "assigned":
set_stored_assignees(
db, event_type, repo_id, event_object["number"], assignees_list
)
if action == "unassigned":
# If the payload STILL has assignees, it means someone is still assigned
# If it's empty (or only contains "Unknown"), delete it.
if not assignees_data or len(assignees_data) == 0:
delete_stored_assignees(
db, event_type, repo_id, event_object["number"]
)
else:
set_stored_assignees(
db,
event_type,
repo_id,
event_object["number"],
assignees_list,
)
print(f"--- {event_object['type'].upper()} {action.capitalize()} ---")
print(f"Title: {event_object['title']}")
print(f"Assignees: {', '.join(event_object['assignees'])}")
print(f"Repo: {event_object['repository']}")
print(f"By: {event_object['sender']}")
# Send to OpenClaw
sendToAgent(event_object)
if action == "assigned":
sendToAgent(event_object, db)
else:
print(f"Action {action} is not configured to send to agent")
elif action == "created" and event_type == "issue_comment":
comment_data = data.get("comment", {})
is_pull = data.get("is_pull", False)
target_data = data.get("issue") or data.get("pull_request") or {}
repository = data.get("repository", {}).get("full_name", "Unknown")
repo_id = data.get("repository", {}).get("id")
sender = data.get("sender", {}).get("login", "Unknown")
issue_number = target_data.get("number")
event_object = {
"type": "issue_comment",
"target_type": "pull_request" if is_pull else "issue",
"action": action,
"repository": repository,
"number": issue_number,
"title": target_data.get("title", "Unknown"),
"sender": sender,
"comment_body": comment_data.get("body", ""),
"url": comment_data.get("html_url"),
}
sendToAgent(event_object, db)
return {
"_shsf": "v2",
@@ -288,6 +432,24 @@ def main(args):
"_res": {"status": "received"},
"_headers": {"Content-Type": "application/json"},
}
elif route == "clear_limit":
# Reset the hourly rate limit counter
try:
reset_rate_limit(db)
return {
"_shsf": "v2",
"_code": 200,
"_res": {"status": "Rate limit reset"},
"_headers": {"Content-Type": "application/json"},
}
except Exception as e:
print(f"Failed to reset rate limit: {e}")
return {
"_shsf": "v2",
"_code": 500,
"_res": {"error": "Failed to reset rate limit"},
"_headers": {"Content-Type": "application/json"},
}
else:
return {
"_shsf": "v2",