diff --git a/CURRENT_STATUS.md b/CURRENT_STATUS.md index ba5706f..fec679f 100644 --- a/CURRENT_STATUS.md +++ b/CURRENT_STATUS.md @@ -1,50 +1,55 @@ # 褒めたもん(metamon_code)現状整理メモ -最終更新: 2026-02-15(Codex調査) +最終更新: 2026-02-15(Codex更新) ## 1. 目的と実行の流れ -- `main/src/hometamon.py` の `main()` がエントリポイント。 -- Home Timeline を取得し、条件に応じて以下を実行する構成。 +- エントリポイントは `main/src/hometamon.py` の `main()`。 +- Home Timeline を読み、条件に応じて以下を実行。 - 挨拶リプ(おはよう / おやすみ) - 褒めリプ(キーワードベース) - 15時のおやつツイート - フォローバック管理 - 実行件数のDMレポート送信 -- Docker運用時は `main/crontab` で **6分ごと** に `python3 /src/hometamon.py` を叩く設計。 - -## 2. 現在のランタイム/依存の特徴 -- READMEの依存記載が古め(Django など現状未使用の記載が残る)。 -- 実際のコンテナ依存は `main/Dockerfile` 側がソースオブトゥルースに近い。 - - `python-dotenv==1.0.0` - - `tweepy==3.8.0`(v1.1 API時代) - - `pytest==5.4.1`, `pytest-mock==3.1.0` -- ローカル直実行では `dotenv` など未導入環境だとテスト収集時に失敗する。 - -## 3. CI/CD の状態 -- GitHub Actions が2本。 - - `ci.yml`: push時に docker-compose build → コンテナ内pytest - - `linter.yml`: PR時に black + reviewdog -- 直近履歴でも CI / linter の調整コミットが入っており、保守は継続されていた形跡あり。 - -## 4. いま見えている技術的リスク -- `main/src/hometamon.py` の `main()` 内で `hometamon.tweet_linestamp()` を呼んでいるが、定義されているのは `test_tweet_linestamp()`。 - - 指定日時に到達すると `AttributeError` になる可能性が高い。 -- Twitter APIが古い実装(`tweepy==3.8.0` + v1.1想定)で、現行運用時は権限・API仕様差分を要確認。 -- 除外ワードや分類はハードコード中心で、運用ルール変更時にコード修正が必要。 -- READMEの手順・依存情報と実態の乖離あり(再起動時のオンボーディング負荷が高い)。 - -## 5. テスト現状(この調査での実行) -- `pytest -q` は、環境に `python-dotenv` が無いため import error で停止。 -- Docker経由テストは、この実行環境に Docker コマンドが無く未実施。 - -## 6. リリース再開に向けた優先TODO(提案) -1. **クリティカルバグ修正**: `tweet_linestamp` 呼び出し不整合を解消。 -2. **Secrets/Env棚卸し**: `.env` 必須値(APIキー、管理者ID等)を再確認。 -3. **実行確認**: `--test` 相当の安全モード(投稿しないdry-run)を用意して疎通確認。 -4. **README更新**: 実行手順を Docker中心に一本化、依存記載を最新化。 -5. **段階リリース**: まず cron 無効 + 手動単発、次に cron 有効化。 - -## 7. 現状理解サマリ(短く) -- Botの基本機能は保たれているが、**そのまま本番再開はやや危険**。 -- 特に `tweet_linestamp` 名称不整合は先に潰すべき。 -- 依存と運用手順の再整備をした上で、段階的に復帰するのが安全。 +- 本番コンテナ(`prd`)では `main/crontab` により 6分ごとに `python3 /src/hometamon.py` を実行する設計。 + +## 2. ランタイム/依存の現状 +- Docker Python は `3.13-alpine`。 +- 依存管理は `requirements.txt` から `pyproject.toml` + `uv.lock` へ移行済み。 +- `main/Dockerfile` は `uv sync --frozen` で `.venv` を作成し、`/app/.venv/bin` を `PATH` に追加。 +- direct dependency(2026-02-15時点) + - `black>=26.1.0` + - `pykakasi>=2.3.0` + - `pytest>=9.0.2` + - `pytest-mock>=3.15.1` + - `python-dotenv>=1.2.1` + - `tqdm>=4.67.3` + - `tweepy>=4.16.0` + +## 3. Docker/開発運用 +- `main/Dockerfile` は multi-stage 構成。 + - `dev`: `CMD ["sh"]` + - `prd`: `crond` 常駐 +- `docker-compose.yml` は本番相当定義、`docker-compose.override.yml` で開発用差分(`target: dev`、volume mount、`command: sh`)を上書き。 +- `Makefile` は `docker compose` コマンドへ統一済み。`--no-cache` は通常運用から削除済み。 + +## 4. CI/CD の現状 +- ワークフローは2本(どちらも `pull_request` トリガー)。 + - `ci.yml`: Docker Buildx + GitHub Actions cache(`type=gha`)で `dev` イメージをビルドし、`pytest tests` を実行。 + - `linter.yml`: `reviewdog/action-black` で black結果をPRコメントとして指摘(warning、failしない設定)。 +- CI高速化として Docker layer cache を導入済み。 + +## 5. テスト現状(最新確認) +- Docker内で `pytest tests` 実行済み。 +- 結果: **62 passed**(警告のみ)。 +- `tweepy` 更新に伴い `OAuthHandler` の deprecation warning は出るが、現時点ではテスト成功。 + +## 6. 残課題/リスク +- `main()` で `hometamon.tweet_linestamp()` を呼んでいる一方、実装は `test_tweet_linestamp()` 名のまま。 + - 条件ヒット時に `AttributeError` となるリスクが継続。 +- `OAuthHandler` は非推奨警告が出るため、`OAuth1UserHandler` への移行を検討したい。 +- Bot判定ロジック(除外ワード等)はハードコード中心で、運用ルール変更時はコード変更が必要。 + +## 7. 次にやると良いこと +1. `tweet_linestamp` 呼び出し不整合を修正。 +2. Tweepy認証実装を `OAuth1UserHandler` へ移行。 +3. `.env` 必須キーのドキュメント化と起動時バリデーション追加。 diff --git a/main/crontab b/main/crontab index f264bb4..90a9028 100644 --- a/main/crontab +++ b/main/crontab @@ -1 +1 @@ -*/6 * * * * python3 /src/hometamon.py \ No newline at end of file +*/6 * * * * cd /app && python3 /app/src/hometamon.py diff --git a/main/src/hometamon.py b/main/src/hometamon.py index 95744e9..d0d0bf9 100644 --- a/main/src/hometamon.py +++ b/main/src/hometamon.py @@ -3,6 +3,7 @@ import random import datetime as dt import unicodedata +from types import SimpleNamespace from dotenv import load_dotenv @@ -16,11 +17,18 @@ class Hometamon: def __init__(self): - if os.path.exists("/env/.env"): - load_dotenv("/env/.env") - elif os.path.exists("env/.env"): - load_dotenv("env/.env") - else: + env_candidates = [ + "/app/env/.env", + "/env/.env", + "env/.env", + ] + loaded = False + for env_path in env_candidates: + if os.path.exists(env_path): + load_dotenv(env_path) + loaded = True + break + if not loaded: print("error doesn't exist .env path") if os.path.dirname("images"): @@ -33,11 +41,20 @@ def __init__(self): access_token = os.environ.get("ACCESS_TOKEN") or "" token_secret = os.environ.get("TOKEN_SECRET") or "" - auth = tweepy.OAuthHandler( + auth = tweepy.OAuth1UserHandler( consumer_key=consumer_key, consumer_secret=consumer_secret ) auth.set_access_token(key=access_token, secret=token_secret) self.api = tweepy.API(auth, wait_on_rate_limit=True) + self.client = None + if consumer_key and consumer_secret and access_token and token_secret: + self.client = tweepy.Client( + consumer_key=consumer_key, + consumer_secret=consumer_secret, + access_token=access_token, + access_token_secret=token_secret, + wait_on_rate_limit=True, + ) self.my_twitter_user_id = os.environ.get("TWITTER_USER_ID") self.manuscript = meta_manuscript.Manuscript() JST = dt.timezone(dt.timedelta(hours=+9), "JST") @@ -188,8 +205,83 @@ def __init__(self): } def get_tweets(self): + if self.client: + if not self.my_twitter_user_id: + raise RuntimeError("TWITTER_USER_ID is required when using v2 client") + response = self.client.get_home_timeline( + id=self.my_twitter_user_id, + max_results=100, + expansions=["author_id"], + tweet_fields=["author_id"], + user_fields=["username", "name", "description"], + user_auth=True, + ) + tweets = response.data or [] + users = {} + if response.includes and "users" in response.includes: + users = {str(user.id): user for user in response.includes["users"]} + legacy_like_tweets = [] + for tweet in tweets: + user = users.get(str(tweet.author_id)) + legacy_like_tweets.append( + SimpleNamespace( + id=tweet.id, + text=tweet.text, + favorited=False, + user=SimpleNamespace( + id=user.id if user else 0, + name=user.name if user else "", + screen_name=user.username if user else "", + description=user.description if user else "", + ), + ) + ) + return legacy_like_tweets return self.api.home_timeline(count=100, since_id=None) + def _tweet(self, status, in_reply_to_status_id=None, image_file=None): + if self.client: + if image_file: + media = self.api.media_upload(filename=image_file) + kwargs = {"text": status, "media_ids": [media.media_id]} + if in_reply_to_status_id: + kwargs["in_reply_to_tweet_id"] = in_reply_to_status_id + self.client.create_tweet(user_auth=True, **kwargs) + else: + kwargs = {"text": status} + if in_reply_to_status_id: + kwargs["in_reply_to_tweet_id"] = in_reply_to_status_id + self.client.create_tweet(user_auth=True, **kwargs) + return + if image_file: + if in_reply_to_status_id is None: + self.api.update_with_media( + filename=image_file, + status=status, + ) + else: + self.api.update_with_media( + filename=image_file, + status=status, + in_reply_to_status_id=in_reply_to_status_id, + ) + else: + if in_reply_to_status_id is None: + self.api.update_status(status=status) + else: + self.api.update_status( + status=status, + in_reply_to_status_id=in_reply_to_status_id, + ) + + def _favorite(self, tweet_id): + if self.client: + if not self.my_twitter_user_id: + raise RuntimeError("TWITTER_USER_ID is required when using v2 client") + self.client.like(self.my_twitter_user_id, tweet_id, user_auth=True) + return + self.api.create_favorite(tweet_id) + def user_name_changer(self, user_name): # 正規化 normalize_user_name = unicodedata.normalize("NFKC", user_name) @@ -210,8 +302,8 @@ def good_morning(self, tweet): # if random.random() < image_ratio: # pass # else: - self.api.update_status(status=reply, in_reply_to_status_id=tweet.id) - self.api.create_favorite(tweet.id) + self._tweet(status=reply, in_reply_to_status_id=tweet.id) + self._favorite(tweet.id) return reply def good_night(self, tweet, image_ratio=0.2): @@ -225,12 +317,12 @@ def good_night(self, tweet, image_ratio=0.2): self.counts["good_night"] += 1 if random.random() < image_ratio: image_file = os.path.join(self.image_dir, "oyasumi_w_newtext.png") - self.api.update_with_media( - filename=image_file, status=reply, in_reply_to_status_id=tweet.id + self._tweet( + status=reply, in_reply_to_status_id=tweet.id, image_file=image_file ) else: - self.api.update_status(status=reply, in_reply_to_status_id=tweet.id) - self.api.create_favorite(tweet.id) + self._tweet(status=reply, in_reply_to_status_id=tweet.id) + self._favorite(tweet.id) return reply def choose_image_by_reply(self, reply: str) -> str: @@ -256,12 +348,12 @@ def praise(self, tweet, image_ratio=0.2): if random.random() < image_ratio: image_name = self.choose_image_by_reply(reply) image_file = os.path.join(self.image_dir, image_name) - self.api.update_with_media( - filename=image_file, status=reply, in_reply_to_status_id=tweet.id + self._tweet( + status=reply, in_reply_to_status_id=tweet.id, image_file=image_file ) else: - self.api.update_status(status=reply, in_reply_to_status_id=tweet.id) - self.api.create_favorite(tweet.id) + self._tweet(status=reply, in_reply_to_status_id=tweet.id) + self._favorite(tweet.id) return reply def tweet_sweet(self): @@ -270,20 +362,24 @@ def tweet_sweet(self): "\n⊂・ー・つ" + chr(int(random.choice(self.manuscript.sweets), 16)) + "\n" ) # 16進数から変換 status += random.choice(self.manuscript.sweet_tweet_after) - self.api.update_status(status=status) + self._tweet(status=status) - def test_tweet_linestamp(self): + def tweet_linestamp(self): reply = "ぼくのLINEスタンプがでたもん!!!ぼくのかわりにみんなをほめてほしいもん!!よろしくもん!!\nhttps://store.line.me/stickershop/product/17652748" image_file = os.path.join(self.image_dir, "stamp", "all.png") - self.api.update_with_media(filename=image_file, status=reply) + self._tweet(status=reply, image_file=image_file) + + # Backward compatibility for existing tests/calls. + def test_tweet_linestamp(self): + self.tweet_linestamp() def test_tweet(self, image_flg=False): status = "起きてるもん!\n⊂・ー・つ" if image_flg: image_file = os.path.join(self.image_dir, "icon.jpg") - self.api.update_with_media(filename=image_file, status=status) + self._tweet(status=status, image_file=image_file) else: - self.api.update_status(status=status) + self._tweet(status=status) self.counts["test"] += 1 return status @@ -300,7 +396,7 @@ def check_exclude(self, tweet): # 除外するかどうかcheck if self.set_task_words[0] in tweet.text: return True else: # 自分に向けてのtweetかつ,設定が入っていないならファボ - self.api.create_favorite(id=tweet.id) + self._favorite(tweet.id) return True elif ( len(tweet.text) >= 80 diff --git a/main/tests/test_hometamon.py b/main/tests/test_hometamon.py index 78e58ac..d41031e 100644 --- a/main/tests/test_hometamon.py +++ b/main/tests/test_hometamon.py @@ -22,6 +22,7 @@ def app(self, mocker): app.manuscript.sweets = ["1F950"] # croissant app.my_twitter_user_id = "966247026416472064" app.api = mocker.MagicMock() + app.client = None return app @pytest.fixture()