diff --git a/files/config_template.yml b/files/config_template.yml index 73db683..a42ff49 100644 --- a/files/config_template.yml +++ b/files/config_template.yml @@ -49,8 +49,20 @@ MATTERMOST: team: "your-mattermost-team-name" # The team name (not display name) channel: "your-mattermost-channel-name" # The channel name (not display name) +# Basecamp configuration +BASECAMP: + is_posting_on: true + access_token: "your-basecamp-access-token" + refresh_token: "your-basecamp-refresh-token" + client_id: "your-basecamp-client-id" + client_secret: "your-basecamp-client-secret" + account_id: "your-basecamp-account-id" + user_agent: "your-basecamp-user-agent" + bucket_id: "your-basecamp-bucket-id" + board_id: "your-basecamp-board-id" + + SLACK_TEST_CHANNEL_ID: "your-slack-test-channel-id" # not required so left outside of dictionary TELEGRAM_TEST_CHANNEL_ID: "your-slack-test-channel-id" # not required so left outside of dictionary MATTERMOST_TEST_CHANNEL_ID: "your-mattermost-test-channel-id" # not required so left outside of dictionary -GOOGLE_TEST_SPREADSHEET_ID: "your-google-test-spreadsheet-id" # not required so left outside of dictionary - +GOOGLE_TEST_SPREADSHEET_ID: "your-google-test-spreadsheet-id" # not required so left outside of dictionary \ No newline at end of file diff --git a/src/PaperBee/daily_posting.py b/src/PaperBee/daily_posting.py index 31054db..8c266a1 100644 --- a/src/PaperBee/daily_posting.py +++ b/src/PaperBee/daily_posting.py @@ -40,6 +40,7 @@ async def daily_papers_search( zulip_args = validate_platform_args(config, "ZULIP") telegram_args = validate_platform_args(config, "TELEGRAM") mattermost_args = validate_platform_args(config, "MATTERMOST") + basecamp_args = validate_platform_args(config, "BASECAMP") if telegram_args == {}: telegram_args = {"bot_token": "", "channel_id": "", "is_posting_on": False} @@ -48,7 +49,18 @@ async def daily_papers_search( if slack_args == {}: slack_args = {"bot_token": "", "channel_id": "", "is_posting_on": False} if mattermost_args == {}: - mattermost_args = {"bot_token": "", "channel_id": "", "is_posting_on": False} + mattermost_args = {"url": "", "token": "", "team": "", "channel": "", "is_posting_on": False} + + if basecamp_args == {}: + basecamp_args = { + "account_id": "", + "client_id": "", + "client_secret": "", + "user_agent": "", + "bucket_id": "", + "board_id": "", + "is_posting_on": False, + } llm_filtering = config.get("LLM_FILTERING", False) if llm_filtering: @@ -85,16 +97,32 @@ async def daily_papers_search( mattermost_token=mattermost_args["token"], mattermost_team=mattermost_args["team"], mattermost_channel=mattermost_args["channel"], + basecamp_client_id=basecamp_args["client_id"], + basecamp_client_secret=basecamp_args["client_secret"], + basecamp_account_id=basecamp_args["account_id"], + basecamp_user_agent=basecamp_args["user_agent"], + basecamp_bucket_id=basecamp_args["bucket_id"], + basecamp_board_id=basecamp_args["board_id"], + basecamp_access_token=basecamp_args["access_token"], + basecamp_refresh_token=basecamp_args["refresh_token"], databases=databases, ) - papers, response_slack, response_telegram, response_zulip, response_mattermost = await finder.run_daily( + ( + papers, + response_slack, + response_telegram, + response_zulip, + response_mattermost, + response_basecamp, + ) = await finder.run_daily( post_to_slack=slack_args["is_posting_on"], post_to_telegram=telegram_args["is_posting_on"], post_to_zulip=zulip_args["is_posting_on"], post_to_mattermost=mattermost_args["is_posting_on"], + post_to_basecamp=basecamp_args["is_posting_on"], ) - return papers, response_slack, response_telegram, response_zulip, response_mattermost + return papers, response_slack, response_telegram, response_zulip, response_mattermost, response_basecamp def main() -> None: @@ -133,7 +161,7 @@ def main() -> None: # Dispatch to the appropriate subcommand if args.command == "post": config = load_config(args.config) - papers, _, _, _, _ = asyncio.run( + papers, _, _, _, _, _ = asyncio.run( daily_papers_search( config, interactive=args.interactive, diff --git a/src/PaperBee/papers/basecamp_papers_formatter.py b/src/PaperBee/papers/basecamp_papers_formatter.py new file mode 100644 index 0000000..c897f3a --- /dev/null +++ b/src/PaperBee/papers/basecamp_papers_formatter.py @@ -0,0 +1,137 @@ +import html +import time +from datetime import datetime +from logging import Logger +from typing import Any, Dict, List + +import requests + + +class BasecampPaperPublisher: + """ + Publish papers (from a spreadsheet) to a Basecamp Message Board. + + Args: + logger: logging.Logger instance. + account_id: Basecamp account id (the {ACCOUNT_ID} in URLs). + client_id, client_secret: OAuth credentials (from launchpad.37signals.com). + client_secret: OAuth credentials (from launchpad.37signals.com). + user_agent: string identifying your app (required by Basecamp). + bucket_id: Basecamp bucket id (the {BUCKET_ID} in URLs). + board_id: Basecamp board id (the {BOARD_ID} in URLs). + access_token: optional initial access token. + refresh_token: refresh token (used to obtain new access tokens). + """ + + LAUNCHPAD_AUTH_URL = "https://launchpad.37signals.com/authorization/token" + API_BASE = "https://3.basecampapi.com" + + def __init__( + self, + logger: Logger, + account_id: str, + client_id: str, + client_secret: str, + user_agent: str, + bucket_id: str, + board_id: str, + access_token: str, + refresh_token: str, + ): + self.account_id = account_id + self.client_id = client_id + self.client_secret = client_secret + self.user_agent = user_agent + self.logger = logger + self.bucket_id = bucket_id + self.board_id = board_id + self.access_token = access_token + self.refresh_token = refresh_token + self._access_expires_at = 0 # epoch seconds when token expires (if known) + + # small session for connection pooling + self._session = requests.Session() + self._session.headers.update({"User-Agent": user_agent, "Accept": "application/json"}) + + def _ensure_access_token(self) -> None: + """Ensure we have a valid access token; refresh if needed.""" + if not self.access_token or time.time() >= self._access_expires_at - 30: + self.logger.debug("Refreshing Basecamp access token...") + self._refresh_access_token() + + def _refresh_access_token(self) -> None: + """Refresh access token using refresh_token.""" + if not self.refresh_token: + msg = "No refresh_token available." + raise RuntimeError(msg) + + # NOTE(Rodrigo): {"type": "refresh"} as in https://github.com/basecamp/api/blob/master/sections/authentication.md + data = { + "type": "refresh", # community examples use this type for refresh + "client_id": self.client_id, + "client_secret": self.client_secret, + "refresh_token": self.refresh_token, + } + resp = requests.post(self.LAUNCHPAD_AUTH_URL, data=data, headers={"User-Agent": self.user_agent}, timeout=30) + if resp.status_code != 200: + self.logger.error("Failed to refresh Basecamp token: %s %s", resp.status_code, resp.text) + resp.raise_for_status() + payload = resp.json() + self.access_token = payload.get("access_token") + expires_in = payload.get("expires_in") + if expires_in: + self._access_expires_at = time.time() + int(expires_in) + # if the server returned a new refresh_token, update it + if payload.get("refresh_token"): + self.refresh_token = payload["refresh_token"] + + # update session auth header + self._session.headers.update({ + "Authorization": f"Bearer {self.access_token}", + "Content-Type": "application/json; charset=utf-8", + }) + + @staticmethod + def _escape_html(text: str) -> str: + return html.escape(text) + + def build_message( + self, + papers: List[List[str]], + ) -> str: + """ + Build a simple HTML body for a Basecamp Message's `content` field. + Basecamp uses HTML rich text for message content. + """ + parts = [] + parts.append("

Good morning ☕ Here are today's papers!

") + # parts.append("

Papers