From 1683dc5a98a7e32b6200ddf24111d827f5529642 Mon Sep 17 00:00:00 2001 From: n0str Date: Sun, 30 Jun 2024 16:05:42 +0300 Subject: [PATCH] Codebase --- .env.sample | 2 +- .github/workflows/build.yml | 37 ++++++++++ Dockerfile | 10 +++ api.py | 59 +++++++++++++++ event_parser.py | 143 ++++++++++++++++++++++++++++++++++++ main.py | 135 ++++++++++++++++++++++++++++++++++ requirements.txt | 18 +++++ 7 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/build.yml create mode 100644 Dockerfile create mode 100644 api.py create mode 100644 event_parser.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.sample b/.env.sample index d251ee1..83f4594 100644 --- a/.env.sample +++ b/.env.sample @@ -1,3 +1,3 @@ API_TOKEN= -BOT_TOKEN= +CODEX_BOT_TOKEN= CHAT_ID= \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..e4d6c48 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,37 @@ +name: Build and push docker image + +on: [push] + +env: + DOCKER_REPO: codex-team/codex-figma-notify + +jobs: + build: + runs-on: ubuntu-20.04 + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Login to GitHub Container Registry + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build and push production image + if: endsWith(github.ref, '/prod') + uses: docker/build-push-action@v2 + with: + context: . + tags: ghcr.io/${{ env.DOCKER_REPO }}:prod + push: ${{ endsWith(github.ref, '/prod') }} + + - name: Build and push stage image + if: endsWith(github.ref, '/stage') + uses: docker/build-push-action@v2 + with: + context: . + tags: ghcr.io/${{ env.DOCKER_REPO }}:stage + push: ${{ endsWith(github.ref, '/stage') }} \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..70d64c8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,10 @@ +FROM python:3.8.7-slim-buster + +WORKDIR /usr/src/app + +COPY requirements.txt . +RUN pip install -r requirements.txt + +COPY . . +ENTRYPOINT ["python"] +CMD ["main.py"] \ No newline at end of file diff --git a/api.py b/api.py new file mode 100644 index 0000000..3cc15b0 --- /dev/null +++ b/api.py @@ -0,0 +1,59 @@ +import os +import requests +from dotenv import load_dotenv + +class FigmaAPI: + + API_V1 = 'https://api.figma.com/v1' + FIGMA_WEB = 'https://www.figma.com/design' + + def __init__(self, token, limit=5): + self._token = token + self.limit = limit + self.headers = { + 'X-FIGMA-TOKEN': self._token, + } + self.timeout = 10 + + @staticmethod + def load_from_env(): + load_dotenv(override=True) + token = os.getenv('API_TOKEN') + limit = int(os.getenv('LIMIT', 10)) + return FigmaAPI(token=token, limit=limit) + + def get_node_url_from_component(self, component): + meta = component['meta'] + return f"{FigmaAPI.FIGMA_WEB}/{meta['file_key']}?node-id={meta['node_id'].replace(':', '-')}" + + def get_component_info(self, key): + r = requests.get(f"{FigmaAPI.API_V1}/components/{key}", headers=self.headers, timeout=self.timeout) + if r.status_code != 200: + print(f"Cannot get info of {key} component: {r.text}") + return None + else: + return r.json() + + def get_versions(self, key): + r = requests.get(f"{FigmaAPI.API_V1}/files/{key}/versions", headers=self.headers, timeout=self.timeout) + if r.status_code != 200: + print(f"Cannot get info of {key} versions: {r.text}") + return None + else: + return list(map(lambda x: x['id'], filter(lambda x: x['label'] is not None, r.json()["versions"])))[:2] + + def get_history(self, key, ids, version): + r = requests.get(f"{FigmaAPI.API_V1}/files/{key}?ids={','.join(ids)}&version={version}", headers=self.headers, timeout=self.timeout) + if r.status_code != 200: + print(f"Cannot get history of {key} for ids={ids} version: {version}") + return None + else: + return r.json() + +class Component: + + def __init__(self, data) -> None: + self.data = data + + def __str__(self) -> str: + pass \ No newline at end of file diff --git a/event_parser.py b/event_parser.py new file mode 100644 index 0000000..0243673 --- /dev/null +++ b/event_parser.py @@ -0,0 +1,143 @@ +import json +import re +from api import FigmaAPI +from jycm.jycm import YouchamaJsonDiffer + +class FigmaEventParser: + + NodeIDRegex = r'(\d+[:-]\d+)' + + def __init__(self, event, figma_api) -> None: + self.event = event + self.api = figma_api + self.message_lines = [] + + @staticmethod + def convert_node_id(node_id): + return node_id.replace(':', '-') + + @staticmethod + def get_node_url_from_component(component): + meta = component['meta'] + return f"{FigmaAPI.FIGMA_WEB}/{meta['file_key']}?node-id={FigmaEventParser.convert_node_id(meta['node_id'])}" + + def modified_components(self): + modified_info = { + 'component_ids': set(), + 'components': [] + } + component_ids = set() + if 'modified_components' in self.event: + for component in self.event['modified_components']: + key = component['key'] + info = self.api.get_component_info(key) + meta = info['meta'] + node_id = FigmaEventParser.convert_node_id(meta['node_id']) + component_ids.add(node_id) + + if 'name' in meta['containing_frame']: + name = f"{meta['containing_frame']['name']}, {meta['name']}" + else: + name = meta['name'] + + modified_info['components'].append([node_id, name]) + + modified_info['component_ids'] = component_ids + return modified_info + + def created_components(self): + created_info = { + 'component_ids': set(), + 'components': [] + } + component_ids = set() + if 'created_components' in self.event: + for component in self.event['created_components']: + key = component['key'] + info = self.api.get_component_info(key) + meta = info['meta'] + component_ids.add(FigmaEventParser.convert_node_id(meta['node_id'])) + + if 'name' in meta['containing_frame']: + name = f"{meta['containing_frame']['name']}, {meta['name']}" + else: + name = meta['name'] + + created_info['components'].append([meta['node_id'], name]) + + created_info['component_ids'] = component_ids + return created_info + + @staticmethod + def traverse_path(obj, path): + new_path = [] + cur = obj + for key in path.split("->"): + if key == "": + return "" + elif key[0]=='[' and key[-1]==']': + cur = cur[int(key[1:-1])] + if 'name' in cur: + new_path.append(f"({cur['name']})") + elif isinstance(cur, str): + new_path.append(cur) + else: + new_path.append(f"ERROR") + else: + cur=cur[key] + new_path.append(key) + return ' -> '.join(new_path) + + @staticmethod + def flatten_object(obj, path): + if isinstance(obj, str): + return obj + else: + if 'componentSetId' in obj: + node_id = obj['componentSetId'] + elif 'id' in obj: + node_id = obj['id'] + else: + node_id = re.findall(FigmaEventParser.NodeIDRegex, path)[-1] + return [FigmaEventParser.convert_node_id(node_id), obj['name']] + + def generate_history_diff(self, left, right): + ycm = YouchamaJsonDiffer(left, right) + ycm.diff() + diff_result = ycm.to_dict(no_pairs=True) + with open("diff.json", "w") as w: + w.write(json.dumps(diff_result)) + + if 'value_changes' in diff_result: + for i, change in enumerate(diff_result['value_changes']): + diff_result['value_changes'][i]['path'] = FigmaEventParser.traverse_path(left, change['left_path']) + del diff_result['value_changes'][i]['left'] + del diff_result['value_changes'][i]['right'] + del diff_result['value_changes'][i]['left_path'] + del diff_result['value_changes'][i]['right_path'] + else: + diff_result['value_changes'] = [] + + if 'dict:add' in diff_result: + for i, change in enumerate(diff_result['dict:add']): + diff_result['dict:add'][i]['path'] = FigmaEventParser.traverse_path(right, change['right_path']) + diff_result['dict:add'][i]['value'] = FigmaEventParser.flatten_object(change['right'], change['right_path']) + del diff_result['dict:add'][i]['left'] + del diff_result['dict:add'][i]['right'] + del diff_result['dict:add'][i]['left_path'] + del diff_result['dict:add'][i]['right_path'] + else: + diff_result['dict:add'] = [] + + if 'list:add' in diff_result: + for i, change in enumerate(diff_result['list:add']): + diff_result['list:add'][i]['path'] = FigmaEventParser.traverse_path(right, change['right_path']) + diff_result['list:add'][i]['value'] = FigmaEventParser.flatten_object(change['right'], change['right_path']) + del diff_result['list:add'][i]['left'] + del diff_result['list:add'][i]['right'] + del diff_result['list:add'][i]['left_path'] + del diff_result['list:add'][i]['right_path'] + else: + diff_result['list:add'] = [] + + return diff_result \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..fb861f1 --- /dev/null +++ b/main.py @@ -0,0 +1,135 @@ +import json +import requests +import re +from api import FigmaAPI +from event_parser import FigmaEventParser +from flask import Flask, request +import json +import os +from datetime import datetime +import random + +app = Flask(__name__) +limit = 10 +changes_limit = 10 +figma_api = FigmaAPI.load_from_env() + +def generate_report(event, created, modified, history_diff): + def insert_link_to_path(path): + nodes = re.findall(r'(\d+[-:]\d+)', path) + for node in nodes: + if node in components_dict: + path = path.replace(node, f"{components_dict[node]}") + return path + lines = [] + file_key = event['file_key'] + + components_dict = {} + for obj in created: + components_dict[obj[0]] = obj[1] + components_dict[obj[0].replace('-', ':')] = obj[1] + for obj in modified: + components_dict[obj[0]] = obj[1] + components_dict[obj[0].replace('-', ':')] = obj[1] + + lines.append(f"🛍 {event['triggered_by']['handle']} published {event['file_name']} library") + + if event['description'] != "": + lines.append(f"\n
{event['description']}
") + + if len(created): + lines.append("\nCreated components\n") + for component in created[:limit]: + lines.append(f"{component[1]}") + if len(created) > limit: + lines.append(f"and {len(created)-limit} more") + + if len(modified): + lines.append("\nModified components\n") + for component in modified[:limit]: + lines.append(f"{component[1]}") + if len(modified) > limit: + lines.append(f"and {len(modified)-limit} more") + + lines.append(f"\nChanges\n") + + changes = [] + for component in history_diff['dict:add']: + component["path"] = insert_link_to_path(component["path"]) + changes.append(f"- Add {component['value'][1]} on path {component['path']}") + + for component in history_diff['list:add']: + component["path"] = insert_link_to_path(component["path"]) + if isinstance(component['value'], str): + changes.append(f"- Set {component['value']} on path {component['path']}") + else: + changes.append(f"- Set {component['value'][1]} on path {component['path']}") + for component in history_diff['value_changes']: + component["path"] = insert_link_to_path(component["path"]) + if component['path'] == 'thumbnailUrl': + continue + changes.append(f"- Change {component['path']} from {component['old']} to {component['new']}") + + lines.extend(changes[:changes_limit]) + if len(changes) > changes_limit: + lines.append(f"and {len(changes)-changes_limit} more") + + return "\n".join(lines) + + +@app.route('/', methods=['POST']) +def store_json(): + # Get the incoming JSON data + data = request.get_json() + if not data: + return "No JSON data received", 400 + + # Generate a filename with the current timestamp + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + prefix = f"{timestamp}-{random.randint(1000, 10000)}" + filename = f"{prefix}-event.txt" + + # Save the JSON data to a file + with open(filename, 'w') as file: + json.dump(data, file) + + event = data + event_parser = FigmaEventParser(event, figma_api) + + created = event_parser.created_components() + modified = event_parser.modified_components() + + component_ids = created['component_ids'] | modified['component_ids'] + + if 'file_key' in event: + versions = figma_api.get_versions(event['file_key']) + history_new = figma_api.get_history(event['file_key'], list(component_ids), versions[0]) + history_old = figma_api.get_history(event['file_key'], list(component_ids), versions[1]) + + with open(f"{prefix}-new.json", "w") as w: + w.write(json.dumps(history_new)) + + with open(f"{prefix}-old.json", "w") as w: + w.write(json.dumps(history_old)) + + diff_report = event_parser.generate_history_diff(history_old, history_new) + + with open("{prefix}-diff.json", "w") as w: + w.write(json.dumps(diff_report)) + + message = generate_report(event, created['components'], modified['components'], diff_report) + with open("{prefix}-message.txt", "w") as w: + w.write(message) + + CODEX_BOT_TOKEN = os.environ.get('CODEX_BOT_TOKEN', None) + if CODEX_BOT_TOKEN is not None: + requests.post(f"https://notify.bot.codex.so/u/{CODEX_BOT_TOKEN}", data={ + "message": message, + "parse_mode": "HTML" + }) + + return "JSON data saved", 200 + + +if __name__ == '__main__': + app.run(host="0.0.0.0",port=80) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..eb37c38 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +blinker==1.8.2 +certifi==2024.6.2 +charset-normalizer==3.3.2 +click==8.1.7 +Flask==3.0.3 +idna==3.7 +itsdangerous==2.2.0 +Jinja2==3.1.4 +json-delta==2.0.2 +jsondiff==2.1.1 +jycm==1.5.0 +MarkupSafe==2.1.5 +python-dotenv==1.0.1 +PyYAML==6.0.1 +requests==2.32.3 +six==1.16.0 +urllib3==2.2.2 +Werkzeug==3.0.3