生成AIで1行もコードを書かず1万行のアプリをつくった話(Cursor,Claude3.7,Node.js) #36

生成AIの進化が止まらない!

新技術を知るには手を動かすのが一番。
ということで、GWに1週間かけて生成AIでアプリをつくってみた。最近はvibe coding(バイブ・コーディング)というらしい。

その結果、AIとの対話で、1行もコードを書かずに、1万行のWEBアプリが出来た・・!

どんなアプリ?

bravesoftで起きていることを可視化して共有するアプリ
その名も「BRAVE FAN BOARD」。オフィスでディスプレイに表示。
bravesoft応援団がbraverを称えたり、Slack等でのアクティブ度を見やすく分析。

アプリを使っている動画がこちら。こんなアプリをAIと1週間でつくった。

僕もイチ推しbraverに登場。AIに褒められてなんかうれしい笑。

開発の進め方>Cursor+Claude3.7を導入

CursorはAIエージェントと対話しながら、環境構築・開発・実行・検証・バグの修正まで全部やってくれてかなり便利。AIと一緒にNode.js、TailwindCSS、GitHub環境を構築。これまではChatGPTなりAIツールにいろいろコピペする必要があった。Cursorなら環境全体をAIが把握・理解して操作・編集までしてくれる。強力なバディといった感じ。

AIにはChatGPTやGeminiも指定できるが、やっぱりClaude3.7が優秀と感じた。

1行たりともソースコードを触らないと誓う

やるからには完全にAIに開発してもらおう。ということで1行もコーディングせず。「こういうソースを書いて、とかデータはこのように保存して。デザインはもうちょっとココを大きく」などと人間に口頭で伝える形で完成までいけた。(ただ、コードやログをある程度理解したうえで指示を判断する局面も多々あり、コーディングの知識が薄い人にはまだまだ難しいのが現状)

データでみる開発

所要時間:50時間程度
画面数:30画面
AIとの会話数:だいたい500回
AIが書いたコード数:約1万行
必要経費:AIツール費1万円未満、会社にあった古いMAC,ディスプレイ

開発シーン動画

実際にCursorで開発しているシーンがこちら。エラーが起きてたので、エラー文を貼り付けて「このエラーを直して」という指示をだした。たったそれだけ。あとは完全自動。実行ボタンを押し続けているとエラーが直ってる。(うまくいかなくてやり直す時も多々ある)

感想)75点までは一瞬

ざっと伝えて動くものをつくるのがホントに早い。ざっくりした指示でOK。必要な関連機能も提案してくれてすぐ実装完了。例えばユーザ一覧やグラフ表示など、ホントに一瞬で出来た。さきにかっちり仕様を決めるのではなく、AIとつくりながら仕様を考えていく感じ。

100点にもっていくのは大変

本番で使えるように細かな最終調整をするのがとても大変だった。例えばデータ取得処理を実行するタイミングの変更。画像表示の細かな調整。ちょっと気の利いた機能を追加したい。などが大変。あっちを変えたらこっちがバグる現象も起きたり。思い通りにいかず、最終調整にかなり時間を取られた。

75点までは10時間。そこから100点にもってくまでに40時間かかるような感じだ。たとえばAIに絵を書いてもらう時も似たようなことが起こる。ざっくりこういう絵を書いて! はすぐに出来るけど。この絵のこの辺にこういう物体を置いて、という細かい指示をすると一気に精度が悪くなり試行回数が増えるあの感じ。

どんどん重くなり精度は悪くなる

ソースが数千行になってくるとAIの思考時間がどんどん長くなり、修正精度も落ちる。
後半戦の方は人間が編集したほうが早いと感じることが多かった。複雑なシステムの開発はまだ難しい。プロトやデータ表示がメインなダッシュボードなどには十分使えるが、設計やアーキテクチャなどちゃんと構築できるわけではないので、それ以上複雑なシステムはAIベースで開発するのは難しい。人間が設計し部分的なコーディングを補佐してもらうのが現在地点。

それでもやりきれた

AIと会話だけで日常利用できるアプリをつくることはけっこう大変だったが、それでもやりきれた。
普段はコーディングしていない人(=僕)が、一週間の会話だけでアプリを作れる時代。
課題や品質問題はあるとしても、まだまだこれからAIが賢くなると想定すると、人間がコーディングする必要性は無くなっていくように思える。機械語やアセンブリ言語を全エンジニアが理解する必要性がなくなったように、人間がコーディングをする必要性もなくなっていきそう。(一方でまだイマイチと感じることも多くどのくらい先なのかは未知数。焦って絶望しすぎないように)

エンジニアは上流へ向かえ!

ものづくりの本質はきっと変わらない。クリエイティブ、アイディア、デザイン、導入運用、継続改善など、より上流を人間が担当しAIを駆使して圧倒的なスピードでものづくりが出来るようになった。

僕が人生で一番コーディングしていたのは20年前学生バイトプログラマ時代。コーディング自体が楽しかった。22歳で起業してからは、仕様やデザインを考えたり、サービスやイベントを立ち上げたり、採用やカルチャーにコミットしたりと、ビジネスの上流で忙しくものづくりを追求してきた。

コーディング・技術全般を理解する人がビジネスで果たせる役割は大きい。AIに過度に期待せず能力のギリギリ最大限を引き出す。スマホの浸透のように10年かけて徐々に、(現場感では一気に)進める。

エンジニアは上流にどんどん向かいビジネスに向き合おう。クリエイティブやコミュニケーションを大切にしよう。これからものづくりがどのように変化するかは誰にもわからない。そして解決すべき課題は盛りだくさん。政治、貧困、戦争、健康、老後、地方、孤独、教育、無気力・・等々課題山積。AIを使ったエンジニアリングで全部解決していこう! 変化の先端に立ち続け、人々の幸福に貢献していくのみ。

おまけ)ソースコードを大公開

ソースコードのメイン部分(index.js)を公開。これがAIが書いた5000行だ!

冗長だったり肥大化してるけどご愛嬌!

const express = require('express');
const dotenv = require('dotenv');
const path = require('path');
const cors = require('cors');
const { WebClient } = require('@slack/web-api');
const axios = require('axios');
const { Anthropic } = require('@anthropic-ai/sdk');
const OpenAI = require('openai');
const fs = require('fs');
const sqlite3 = require('sqlite3').verbose();
const multer = require('multer');
const { google } = require('googleapis');
const { JWT } = require('google-auth-library');

// 日本時間の現在日時を取得するヘルパー関数
function getJSTDate() {
  const now = new Date();
  return new Date(now.getTime() + (9 * 60 * 60 * 1000)); // UTC+9時間(日本時間)
}

// 日本時間の現在日時からYYYY-MM-DD形式の日付文字列を取得
function getJSTDateString() {
  return getJSTDate().toISOString().split('T')[0];
}

// 設定ファイルのパス
const SETTINGS_FILE_PATH = path.join(__dirname, 'settings.json');

// デフォルトの設定値
const defaultSettings = {
  channelId: process.env.SLACK_CHANNEL_ID || '',
  botToken: process.env.SLACK_BOT_TOKEN || ''
};

// デフォルトAI分析プロンプト
const defaultAiAnalysisPrompt = "あなたは優れた人材分析のプロフェッショナルです。Slackの会話データからユーザーの才能や強み、独自性を見出し、具体的なエピソードを交えながら解説する能力に優れています。分析では、ユーザー固有の特徴を具体的に指摘し、実際の会話からの例を引用しながら、その人だけが持つ価値を明らかにしてください。批判的な内容は一切含めず、明るく前向きな観点からその人の魅力を最大600文字で表現してください。";

// 現在のAI分析プロンプト(初期値はデフォルト)
let currentAiAnalysisPrompt = defaultAiAnalysisPrompt;

// アプリケーション起動時に設定ファイルを読み込む
try {
  const settings = getSettings();
  if (settings.aiAnalysisPrompt) {
    currentAiAnalysisPrompt = settings.aiAnalysisPrompt;
    console.log('設定ファイルからAI分析プロンプトを読み込みました');
  }
} catch (error) {
  console.error('設定ファイルの読み込みに失敗しました:', error);
}

// SQLiteデータベースを初期化
const db = new sqlite3.Database('./slack_data.db');

// データベースのテーブルを初期化
function initDatabase() {
  console.log('データベースを初期化しています...');
  
  // メッセージテーブルの作成
  db.run(`
    CREATE TABLE IF NOT EXISTS slack_messages (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      message_id TEXT,
      user_id TEXT,
      channel_id TEXT,
      channel_name TEXT,
      text TEXT,
      message_type TEXT,
      timestamp TEXT,
      thread_ts TEXT,
      is_reply INTEGER DEFAULT 0,
      reactions TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
  
  // 最終同期日テーブルの作成
  db.run(`
    CREATE TABLE IF NOT EXISTS last_sync_date (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      last_sync_date DATE UNIQUE,
      last_sync_timestamp INTEGER,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
  
  // スレッド返信テーブルの作成
  db.run(`
    CREATE TABLE IF NOT EXISTS slack_thread_replies (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      message_id TEXT,
      parent_message_id TEXT,
      user_id TEXT,
      channel_id TEXT,
      text TEXT,
      timestamp TEXT,
      reply_id TEXT,
      thread_ts TEXT,
      channel_name TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
  
  // スタンプ(リアクション)テーブルの作成
  db.run(`
    CREATE TABLE IF NOT EXISTS slack_reactions (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      message_id TEXT,
      channel_id TEXT,
      user_id TEXT,
      name TEXT,
      timestamp TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
  
  // ユーザーテーブルの作成
  db.run(`
    CREATE TABLE IF NOT EXISTS slack_users (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id TEXT UNIQUE,
      name TEXT,
      real_name TEXT,
      display_name TEXT,
      avatar TEXT,
      is_bot INTEGER DEFAULT 0,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
  
  // チャンネルテーブルの作成
  db.run(`
    CREATE TABLE IF NOT EXISTS slack_channels (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      channel_id TEXT UNIQUE,
      name TEXT,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
  
  // ユーザー分析結果テーブルの作成
  db.run(`
    CREATE TABLE IF NOT EXISTS user_analysis (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id TEXT,
      analysis_json TEXT,
      analyzed_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
  
  // 今日のイチ推しbraverテーブルの作成
  db.run(`
    CREATE TABLE IF NOT EXISTS daily_pickup_user (
      id INTEGER PRIMARY KEY AUTOINCREMENT,
      user_id TEXT,
      name TEXT,
      real_name TEXT,
      display_name TEXT,
      avatar TEXT,
      message_count INTEGER,
      reply_count INTEGER,
      attendance_count INTEGER,
      office_count INTEGER,
      report_count INTEGER,
      sent_reaction_count INTEGER,
      received_reaction_count INTEGER,
      analysis TEXT,
      pickup_date DATE DEFAULT CURRENT_DATE,
      created_at DATETIME DEFAULT CURRENT_TIMESTAMP
    )
  `);
  
  console.log('データベース初期化が完了しました');
}

// アプリ起動時にデータベース初期化
initDatabase();

// 環境変数の読み込み(必ず最初に実行)
dotenv.config();

// .renvファイルからも環境変数を読み込み
try {
  if (fs.existsSync('.renv')) {
    const renvContent = fs.readFileSync('.renv', 'utf8');
    const renvLines = renvContent.split('\n');
    
    renvLines.forEach(line => {
      const trimmedLine = line.trim();
      if (trimmedLine && !trimmedLine.startsWith('#') && trimmedLine.includes('=')) {
        const [key, ...valueParts] = trimmedLine.split('=');
        const value = valueParts.join('='); // APIキーにはしばしば = が含まれることがあるため
        if (key && value) {
          process.env[key.trim()] = value.trim();
          console.log(`環境変数を.renvから読み込み: ${key.trim()} = 設定済み`);
        }
      }
    });
    console.log('.renvファイルからの環境変数読み込みが完了しました');
  }
} catch (error) {
  console.error('.renvファイルの読み込みに失敗しました:', error.message);
}

// Expressアプリの初期化
const app = express();
const port = process.env.PORT || 3000;

// Claude API設定を取得
const anthropic = new Anthropic({
  apiKey: process.env.ANTHROPIC_API_KEY
});

// OpenAI API設定を取得
let openai = null;
if (process.env.OPENAI_API_KEY) {
  openai = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  });
  console.log('OpenAI APIキーが設定されました');
} else {
  console.log('OpenAI APIキーが未設定です');
}

// CORSを有効化
app.use(cors({
  origin: true,
  methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
  allowedHeaders: ['Content-Type', 'Authorization'],
  credentials: true
}));

// JSONボディパーサーを使用
app.use(express.json());
app.use(express.urlencoded({ extended: true }));

// 静的ファイルの提供
app.use(express.static(path.join(__dirname, 'public')));

// Slack API設定を環境変数から取得
const slackToken = process.env.SLACK_BOT_TOKEN;

// 複数のチャンネルIDをサポート
let slackChannelIds = [];

// 環境変数からチャンネルIDを取得
if (process.env.SLACK_CHANNEL_IDS) {
  // カンマ区切りの複数チャンネルIDをサポート
  slackChannelIds = process.env.SLACK_CHANNEL_IDS.split(',').map(id => id.trim()).filter(id => id);
  console.log(`環境変数から複数チャンネルIDを読み込みました: ${slackChannelIds.length}件`);
} else if (process.env.SLACK_CHANNEL_ID) {
  // 後方互換性のために単一チャンネルIDもサポート
  slackChannelIds = [process.env.SLACK_CHANNEL_ID];
  console.log(`環境変数から単一チャンネルIDを読み込みました: ${process.env.SLACK_CHANNEL_ID}`);
  
  // SLACK_CHANNEL_IDSがなければ設定(一貫性のため)
  process.env.SLACK_CHANNEL_IDS = process.env.SLACK_CHANNEL_ID;
  console.log('環境変数SLACK_CHANNEL_IDSを設定しました:', process.env.SLACK_CHANNEL_IDS);
}

// グローバル変数としても設定
global.slackChannelIds = slackChannelIds;

// 設定の検証
console.log('環境変数: SLACK_BOT_TOKEN', slackToken ? '設定済み' : '未設定');
console.log('環境変数: SLACK_CHANNEL_IDS', slackChannelIds.length > 0 ? `設定済み (${slackChannelIds.join(', ')})` : '未設定');
console.log('グローバル変数: slackChannelIds', global.slackChannelIds ? `設定済み (${global.slackChannelIds.join(', ')})` : '未設定');

// WebClientの初期化
const slackClient = new WebClient(slackToken);

// メッセージを分析する関数 (旧名:analyzeTextWithGPT)
async function analyzeTextWithAI(messages, userMap) {
  try {
    console.log('分析開始: 元のメッセージ数:', messages.length);
    
    // 400文字以上のメッセージのみをフィルタリング
    const longMessages = messages.filter(msg => {
      return msg.text && msg.text.length >= 400;
    });
    
    console.log(`400文字以上のメッセージをフィルタリング: ${longMessages.length}件/${messages.length}件`);
    
    // 分析対象がゼロの場合は早期リターン
    if (longMessages.length === 0) {
      console.log('400文字以上のメッセージが見つかりません。分析をスキップします。');
      return {
        summary: { mainTopics: [], overallSummary: "400文字以上のメッセージが見つかりませんでした。" },
        trendAnalysis: { keywords: [], topicChanges: "分析なし", communicationPattern: "分析なし" },
        userAnalysis: {},
        insightsAndRecommendations: ["400文字以上の詳細な投稿がありません。"]
      };
    }
    
    // 以降の処理では messages の代わりに longMessages を使用
    
    // メッセージをユーザーごとにグループ化
    const messagesByUser = {};
    longMessages.forEach(msg => {
      const userId = msg.user;
      const userName = userMap[userId]?.name || 'Unknown User';
      
      if (!messagesByUser[userName]) {
        messagesByUser[userName] = [];
      }
      
      messagesByUser[userName].push({
        text: msg.text,
        ts: msg.ts,
        timestamp: new Date(parseFloat(msg.ts) * 1000).toLocaleString()
      });
    });
    
    // 時系列順にソートされたメッセージ
    const sortedMessages = longMessages
      .map(msg => ({
        text: msg.text,
        user: userMap[msg.user]?.name || 'Unknown User',
        timestamp: new Date(parseFloat(msg.ts) * 1000)
      }))
      .sort((a, b) => a.timestamp - b.timestamp);
    
    // 分析用のプロンプトを作成(基本情報)
    const basePrompt = `
あなたはSlackのメッセージを分析する専門家です。以下のSlackチャンネルのメッセージのうち、400文字以上の詳細な投稿のみを分析して、次の点をJSON形式で詳細に回答してください:

1. 全体サマリー: 長文投稿の内容と傾向(主要トピック、議論の流れ、主な結論など)
2. 長文投稿の傾向分析: キーワード、トピックの特徴、表現の特徴など
3. ユーザー分析: 長文を投稿するユーザーのコミュニケーションスタイル、興味関心、役割など

データ情報:
- 長文メッセージ数: ${longMessages.length}件(全${messages.length}件中)
- 長文投稿ユーザー数: ${Object.keys(messagesByUser).length}名
- 期間: ${sortedMessages.length > 0 ? 
    `${sortedMessages[0].timestamp.toLocaleString()} から ${sortedMessages[sortedMessages.length-1].timestamp.toLocaleString()}` : 
    '不明'}

以下は各ユーザーの長文メッセージ例です:
`;
    
    // ユーザーごとのメッセージ例を追加(各ユーザー最大5つのメッセージ)
    let userMessagesPrompt = '';
    Object.entries(messagesByUser).forEach(([userName, messages]) => {
      userMessagesPrompt += `【${userName}】\n`;
      messages
        .slice(0, 5) // 最大5つまで
        .forEach((msg, i) => {
          userMessagesPrompt += `${i+1}. ${msg.timestamp}: ${msg.text}\n`;
        });
      userMessagesPrompt += '\n';
    });
    
    // 時系列分析用のプロンプト(最初から最後まで10件程度ピックアップ)
    let timelinePrompt = '\n【時系列でのメッセージサンプル】\n';
    const timelineSamples = [];
    
    if (sortedMessages.length <= 10) {
      // 10件以下なら全て
      timelineSamples.push(...sortedMessages);
    } else {
      // 最初2件
      timelineSamples.push(sortedMessages[0], sortedMessages[1]);
      
      // 中間部分から数件
      const middleStartIdx = Math.floor(sortedMessages.length * 0.3);
      const middleEndIdx = Math.floor(sortedMessages.length * 0.7);
      const step = Math.floor((middleEndIdx - middleStartIdx) / 4);
      
      for (let i = middleStartIdx; i <= middleEndIdx; i += step) {
        if (timelineSamples.length < 8) {
          timelineSamples.push(sortedMessages[i]);
        }
      }
      
      // 最後2件
      timelineSamples.push(
        sortedMessages[sortedMessages.length - 2],
        sortedMessages[sortedMessages.length - 1]
      );
    }
    
    timelineSamples.forEach((msg, i) => {
      timelinePrompt += `${msg.timestamp.toLocaleString()}: ${msg.user}: ${msg.text}\n`;
    });
    
    // メッセージ全体から主要キーワードを抽出
    const allMessages = longMessages.map(msg => msg.text).join(' ').toLowerCase();
    const keywords = extractKeywords(allMessages);
    const keywordsText = keywords.join(', ');
    
    // レスポンス形式のガイドライン
    const responseFormatPrompt = `
分析結果は以下のJSON形式で返してください:

{
  "summary": {
    "mainTopics": ["トピック1", "トピック2"],
    "overallSummary": "長文投稿全体の要約...",
    "keyConclusions": ["結論1", "結論2"]
  },
  "trendAnalysis": {
    "keywords": ["キーワード1", "キーワード2"],
    "topicChanges": "トピックの変化についての説明...",
    "communicationPattern": "長文投稿のパターンの説明..."
  },
  "userAnalysis": {
    "ユーザー名1": {
      "communicationStyle": "長文投稿のスタイルの説明...",
      "topics": ["興味のあるトピック1", "興味のあるトピック2"],
      "role": "会話内での役割...",
      "personality": "性格やコミュニケーション特性..."
    },
    "ユーザー名2": {
      "communicationStyle": "長文投稿のスタイルの説明...",
      "topics": ["興味のあるトピック1", "興味のあるトピック2"],
      "role": "会話内での役割...",
      "personality": "性格やコミュニケーション特性..."
    }
  },
  "insightsAndRecommendations": [
    "洞察1...",
    "洞察2..."
  ]
}

キーワード抽出結果: ${keywordsText}
※この分析では、400文字以上の長文投稿のみを対象としています。
`;

    // トピックの変化を検出
    const topicChanges = detectTopicChanges(sortedMessages);
    
    // アクティビティパターンを分析
    const activityPattern = analyzeActivityPattern(sortedMessages);
    
    // 送信前にトークン数をチェックし、必要に応じてメッセージを圧縮
    const compressForTokenLimit = (userMessagesPrompt, timelinePrompt, maxTotalTokens = 100000) => {
      // 簡易的なトークン数計算関数
      const estimateTokens = (text) => {
        const japaneseChars = text.match(/[\u3000-\u303f\u3040-\u309f\u30a0-\u30ff\uff00-\uff9f\u4e00-\u9faf]/g) || [];
        const otherChars = text.length - japaneseChars.length;
        return Math.ceil(japaneseChars.length / 2 + otherChars / 4);
      };
      
      // 各部分のトークン数を計算
      const userMsgTokens = estimateTokens(userMessagesPrompt);
      const timelineTokens = estimateTokens(timelinePrompt);
      const baseAndFormatTokens = 3000; // システムプロンプトと構造部分のおおよどのトークン数
      
      const totalEstimatedTokens = userMsgTokens + timelineTokens + baseAndFormatTokens;
      console.log(`推定トークン数: ユーザーメッセージ=${userMsgTokens}, タイムライン=${timelineTokens}, 合計=${totalEstimatedTokens}`);
      
      // トークン数が上限を超えていなければそのまま返す
      if (totalEstimatedTokens <= maxTotalTokens) {
        return { userMessagesPrompt, timelinePrompt, totalEstimatedTokens };
      }
      
      console.log(`トークン数が上限(${maxTotalTokens})を超えています。メッセージを圧縮します...`);
      
      // 削減が必要な量を計算
      const excessTokens = totalEstimatedTokens - maxTotalTokens;
      const reduceRatio = 1 - (excessTokens / (userMsgTokens + timelineTokens));
      
      // ユーザーメッセージとタイムラインを比例配分で削減
      let compressedUserMsgs = userMessagesPrompt;
      let compressedTimeline = timelinePrompt;
      
      // 1. まずタイムラインを削減(最も削減しやすい)
      if (timelineTokens > 1000) {
        // タイムラインを大幅に削減
        const timelineLines = timelinePrompt.split('\n');
        const keptLines = Math.max(5, Math.floor(timelineLines.length * reduceRatio));
        
        // 重要なポイントのみを残す(先頭、中間、末尾)
        const selectedLines = [];
        selectedLines.push(timelineLines[0]); // ヘッダー
        
        if (timelineLines.length > 2) {
          selectedLines.push(timelineLines[1]); // 最初のサンプル
          
          // 中間のサンプルから数個(均等間隔)
          const step = Math.floor((timelineLines.length - 3) / (keptLines - 3));
          for (let i = 0; i < keptLines - 3; i++) {
            const idx = 1 + step * (i + 1);
            if (idx < timelineLines.length - 1) {
              selectedLines.push(timelineLines[idx]);
            }
          }
          
          selectedLines.push(timelineLines[timelineLines.length - 1]); // 最後のサンプル
        }
        
        compressedTimeline = selectedLines.join('\n');
        console.log(`タイムラインを ${timelineLines.length} 行から ${selectedLines.length} 行に削減しました`);
      }
      
      // 2. 次にユーザーメッセージを削減(各ユーザーのサンプル数を減らす)
      if (estimateTokens(compressedTimeline) + estimateTokens(compressedUserMsgs) + baseAndFormatTokens > maxTotalTokens) {
        // ユーザーごとのブロックに分割
        const userBlocks = compressedUserMsgs.split(/【(.+?)】/).filter(block => block.trim());
        const userSections = [];
        
        for (let i = 0; i < userBlocks.length; i += 2) {
          if (i + 1 < userBlocks.length) {
            userSections.push({
              user: userBlocks[i],
              content: userBlocks[i+1]
            });
          }
        }
        
        console.log(`ユーザーセクション数: ${userSections.length}`);
        
        // 各ユーザーのメッセージサンプルを削減
        const compressedSections = userSections.map(section => {
          const messages = section.content.split(/\d+\. /).filter(msg => msg.trim());
          
          if (messages.length <= 2) return `【${section.user}】\n${section.content}`;
          
          // サンプル数を2つだけに削減
          const firstSample = messages[1]; // 0番目は空か前のセパレータの可能性
          const lastSample = messages[messages.length - 1];
          
          return `【${section.user}】\n1. ${firstSample}\n2. ${lastSample}\n`;
        });
        
        compressedUserMsgs = compressedSections.join('\n');
        console.log('各ユーザーのサンプル数を削減しました');
      }
      
      // 3. それでも多すぎる場合はユーザー数を削減
      if (estimateTokens(compressedTimeline) + estimateTokens(compressedUserMsgs) + baseAndFormatTokens > maxTotalTokens) {
        // ユーザーごとのブロックに分割
        const userBlocks = compressedUserMsgs.split(/【(.+?)】/).filter(block => block.trim());
        const userSections = [];
        
        for (let i = 0; i < userBlocks.length; i += 2) {
          if (i + 1 < userBlocks.length) {
            userSections.push({
              user: userBlocks[i],
              content: userBlocks[i+1]
            });
          }
        }
        
        // ユーザー数を削減(メッセージ数の多い上位のみを残す)
        const maxUsers = Math.max(5, Math.floor(userSections.length * 0.5));
        const keptSections = userSections.slice(0, maxUsers);
        
        compressedUserMsgs = keptSections.map(section => `【${section.user}】\n${section.content}`).join('\n');
        console.log(`ユーザー数を ${userSections.length} から ${keptSections.length} に削減しました`);
      }
      
      // 最終的なトークン数を再計算
      const finalTokens = estimateTokens(compressedTimeline) + estimateTokens(compressedUserMsgs) + baseAndFormatTokens;
      console.log(`圧縮後の推定トークン数: ${finalTokens}`);
      
      return { 
        userMessagesPrompt: compressedUserMsgs, 
        timelinePrompt: compressedTimeline,
        totalEstimatedTokens: finalTokens
      };
    };

    // OpenAIを使って分析
    if (openai && process.env.OPENAI_API_KEY) {
      try {
        console.log('OpenAIを使用して分析を開始します...');
        console.log('OpenAI APIキー確認:', process.env.OPENAI_API_KEY ? 'APIキー設定済み' : 'APIキー未設定');
        console.log('メッセージ数:', longMessages.length);
        console.log('ユーザー数:', Object.keys(messagesByUser).length);
        
        if (!process.env.OPENAI_API_KEY) {
          throw new Error('OpenAI APIキーが設定されていません');
        }
        
        // システムプロンプトとユーザープロンプトを作成
        const systemPrompt = "あなたは優秀な人材分析のプロです。Slackの会話から具体的なエピソード、案件名、人名を使って600文字程度で簡潔に分析してください。1) 関わった具体的なプロジェクト、2) 発言から見える専門性、3) チーム貢献の実例、4) コミュニケーションの特徴的なパターン、5) 具体的な強みを必ず含めてください。簡潔な文体で、具体的なエピソードを根拠にした分析を行ってください。";
        
        // トークン数の削減処理を適用
        const { userMessagesPrompt: compressedUserMsgs, timelinePrompt: compressedTimeline, totalEstimatedTokens } = 
          compressForTokenLimit(userMessagesPrompt, timelinePrompt, 8000); // GPT-4は入力トークン制限が低いため調整
        
        // 最終的なプロンプトを作成(圧縮バージョンを使用)
        const finalPrompt = basePrompt + compressedUserMsgs + compressedTimeline + responseFormatPrompt;
        
        console.log('OpenAIリクエストを送信します...');
        console.log(`推定トークン数: ${totalEstimatedTokens}`);
        
        try {
          // OpenAI APIを呼び出す(トークン制限に配慮)
          const response = await openai.chat.completions.create({
            model: "gpt-4o",
            temperature: 0.2,
            max_tokens: 3000,
            messages: [
              {
                role: "system",
                content: "あなたは優秀な人材分析のプロです。Slackの会話から具体的なエピソード、案件名、人名を使って600文字程度で簡潔に分析してください。1) 関わった具体的なプロジェクト、2) 発言から見える専門性、3) チーム貢献の実例、4) コミュニケーションの特徴的なパターン、5) 具体的な強みを必ず含めてください。簡潔な文体で、具体的なエピソードを根拠にした分析を行ってください。"
              },
              {
                role: "user",
                content: finalPrompt
              }
            ]
          });
          
          console.log('OpenAIからの応答を受信しました');
          
          // OpenAI APIからの応答を処理
          if (response && response.choices && response.choices.length > 0) {
            const aiResponse = response.choices[0].message.content;
            console.log('応答内容の一部:', aiResponse.substring(0, 100) + '...');
            
            // JSONをパース
            let analysisResult;
            try {
              // 前後の余分なテキストを削除して純粋なJSONだけを抽出
              let jsonString = aiResponse.trim();
              
              // もし```json```で囲まれていたら、その部分だけを抽出
              const jsonBlockMatch = jsonString.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
              if (jsonBlockMatch && jsonBlockMatch[1]) {
                jsonString = jsonBlockMatch[1].trim();
                console.log('マークダウンコードブロックから純粋なJSONを抽出しました');
              }
              
              // JSON解析
              analysisResult = JSON.parse(jsonString);
              console.log('JSON解析成功');
              
              return analysisResult;
            } catch (parseError) {
              console.error('JSON解析エラー:', parseError.message);
              
              // JSON修正を試みる
              try {
                const fixedJsonString = cleanupAllProblematicCharacters(aiResponse);
                analysisResult = JSON.parse(fixedJsonString);
                console.log('JSON修正後の解析成功');
                return analysisResult;
              } catch (fixError) {
                console.error('JSON修正後も解析失敗:', fixError.message);
                // 簡易分析に切り替え
                return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
              }
            }
          } else {
            console.warn('OpenAIの応答形式が想定外です');
            return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
          }
        } catch (apiError) {
          console.error('OpenAI API呼び出しエラー:', apiError);
          return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
        }
      } catch (error) {
        console.warn('OpenAI APIエラー:', error.message);
        return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
      }
    }
    
    // OpenAIが使えない場合のフォールバック
    console.warn('OpenAIが使用できないため、簡易分析を使用します');
    return createSimpleAnalysis(longMessages, messagesByUser, keywords, topicChanges, activityPattern);
  } catch (error) {
    console.error('テキスト分析中にエラーが発生しました:', error);
    return {
      error: 'テキスト分析に失敗しました',
      details: error.message
    };
  }
}

// Claudeが使えない場合または失敗した場合のフォールバック分析関数
function createSimpleAnalysis(messages, messagesByUser, keywords, topicChanges, activityPattern) {
  console.warn('簡易分析を使用します(Claudeは使用しません)');
  const analysisResult = {
    channelInsights: "このチャンネルは主にプロジェクト関連の技術的な議論と更新情報の共有に使用されています。会話はプロフェッショナルでありながらもカジュアルなトーンです。",
    channelSummary: {
      mainKeywords: keywords,
      topicChanges: topicChanges,
      activityPattern: activityPattern
    },
    userAnalysis: {}
  };
  
  // 各ユーザーのテキストを簡易分析
  Object.entries(messagesByUser).forEach(([userName, messages]) => {
    const joinedText = messages.map(m => m.text).join(' ').toLowerCase();
    let style = "";
    let interests = "";
    let personality = "";
    
    // 簡易的なキーワード分析
    if (joinedText.includes('問題') || joinedText.includes('エラー')) {
      style += "問題解決指向、";
      personality += "分析的、";
    }
    if (joinedText.includes('思う') || joinedText.includes('考え')) {
      style += "内省的、";
      personality += "思慮深い、";
    }
    if (joinedText.includes('!') || joinedText.includes('?')) {
      style += "質問が多い、";
      personality += "好奇心旺盛、";
    }
    if (joinedText.includes('api') || joinedText.includes('コード')) {
      interests += "プログラミング、";
    }
    if (joinedText.includes('slack') || joinedText.includes('メッセージ')) {
      interests += "コミュニケーションツール、";
    }
    
    analysisResult.userAnalysis[userName] = {
      communicationStyle: style ? style.slice(0, -1) : "データ不足で判断できません",
      topics: interests ? interests.slice(0, -1).split('、') : ["データ不足"],
      role: messages.length > (messages.length / Object.keys(messagesByUser).length) * 1.5 
          ? "主要な発言者" 
          : "通常の参加者",
      personality: personality ? personality.slice(0, -1) : "データ不足で判断できません",
      messageCount: messages.length,
      averageLength: Math.round(messages.map(m => m.text).join(' ').length / messages.length)
    };
  });
  
  return analysisResult;
}

// メッセージからキーワードを抽出する補助関数
function extractKeywords(text) {
  // 簡易的なキーワード抽出(実際の実装ではもっと洗練された方法を使用)
  const commonWords = ['て', 'に', 'は', 'を', 'の', 'が', 'と', 'です', 'ます', 'した', 'から', 'ない', 'いる', 'ある', 'れる'];
  const words = text.replace(/[.,\/#!$%\^&\*;:{}=\-_`~()]/g, '').toLowerCase().split(/\s+/);
  
  const wordCounts = {};
  words.forEach(word => {
    if (word.length > 1 && !commonWords.includes(word)) {
      wordCounts[word] = (wordCounts[word] || 0) + 1;
    }
  });
  
  return Object.entries(wordCounts)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 5)
    .map(entry => entry[0]);
}

// メッセージからトピックの変化を検出する補助関数
function detectTopicChanges(messages) {
  if (messages.length < 3) return "メッセージが少ないため、トピックの変化を検出できません";
  
  // 簡易的なトピック変化検出
  const firstThirdKeywords = extractKeywords(messages.slice(0, Math.floor(messages.length / 3)).map(m => m.text).join(' '));
  const lastThirdKeywords = extractKeywords(messages.slice(-Math.floor(messages.length / 3)).map(m => m.text).join(' '));
  
  // キーワードの違いを確認
  const differentKeywords = lastThirdKeywords.filter(kw => !firstThirdKeywords.includes(kw));
  
  if (differentKeywords.length > 0) {
    return `会話の途中でトピックが変化した可能性があります。最近のキーワード: ${differentKeywords.join(', ')}`;
  } else {
    return "会話全体を通して一貫したトピックが維持されています";
  }
}

// メッセージのアクティビティパターンを分析する補助関数
function analyzeActivityPattern(messages) {
  if (messages.length < 3) return "メッセージが少ないため、アクティビティパターンを分析できません";
  
  // メッセージの時間間隔を計算
  const intervals = [];
  for (let i = 1; i < messages.length; i++) {
    intervals.push(messages[i].timestamp - messages[i-1].timestamp);
  }
  
  const avgInterval = intervals.reduce((sum, val) => sum + val, 0) / intervals.length;
  
  // 最も活発な参加者を特定
  const userCounts = {};
  messages.forEach(msg => {
    userCounts[msg.user] = (userCounts[msg.user] || 0) + 1;
  });
  
  const mostActiveUser = Object.entries(userCounts)
    .sort((a, b) => b[1] - a[1])[0][0];
  
  // 返却するパターン情報
  return {
    averageTimeBetweenMessages: Math.round(avgInterval) + "秒",
    mostActiveUser: mostActiveUser,
    conversationPace: avgInterval < 60 ? "非常に活発な会話" : 
                     avgInterval < 300 ? "活発な会話" : 
                     avgInterval < 1800 ? "通常のペースの会話" : "間隔の空いた会話"
  };
}


// シンプルテストページ(デバッグ用)
app.get('/simple', (req, res) => {
  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
      <title>Slack テスト</title>
      <style>
        body { font-family: Arial; max-width: 800px; margin: 0 auto; padding: 20px; background: #f4f4f4; }
        button { padding: 10px; background: #4CAF50; color: white; border: none; cursor: pointer; margin: 5px; }
        #messages { margin-top: 20px; border: 1px solid #ddd; padding: 15px; min-height: 200px; background: white; }
      </style>
    </head>
    <body>
      <h1>Slack テスト</h1>
      <button id="getSlackMessages">Slackメッセージを取得</button>
      <div id="messages">ここに結果が表示されます...</div>

      <script>
        document.getElementById('getSlackMessages').addEventListener('click', async () => {
          const messagesDiv = document.getElementById('messages');
          messagesDiv.innerHTML = 'Slackメッセージ読み込み中...';
          
          try {
            const response = await fetch('/api/slack-messages');
            const data = await response.json();
            
            if (data.error) {
              messagesDiv.innerHTML = '<p>エラー: ' + data.error + '</p>';
              return;
            }
            
            let html = '<h3>Slackメッセージ取得成功!</h3>';
            html += '<ul>';
            data.forEach(msg => {
              html += '<li>';
              html += '<strong>' + (msg.userName || 'Unknown User') + '</strong> ';
              html += '<span style="color: #888;">(' + msg.timestamp + ')</span>: ';
              html += msg.text;
              html += '</li>';
            });
            html += '</ul>';
            messagesDiv.innerHTML = html;
          } catch (error) {
            messagesDiv.innerHTML = '<p>エラー: ' + error.message + '</p>';
          }
        });
      </script>
    </body>
    </html>
  `);
});

// Slackからメッセージを取得するAPI
app.get('/api/slack-messages', async (req, res) => {
  try {
    const requestedChannelId = req.query.channelId;
    // チャンネルIDが指定されているか確認
    let targetChannelId;
    
    if (requestedChannelId) {
      // リクエストで指定されたチャンネルIDを使用
      targetChannelId = requestedChannelId;
      console.log(`リクエストで指定されたチャンネルID: ${targetChannelId}`);
    } else if (global.slackChannelIds && global.slackChannelIds.length > 0) {
      // グローバル変数からチャンネルIDを取得
      targetChannelId = global.slackChannelIds[0];
      console.log(`グローバル変数からのデフォルトチャンネルID: ${targetChannelId}`);
    } else if (slackChannelIds.length > 0) {
      // デフォルトは最初のチャンネルを使用
      targetChannelId = slackChannelIds[0];
      console.log(`デフォルトチャンネルID: ${targetChannelId}`);
    } else {
      console.error('Slack API設定が不足しています');
      return res.status(500).json({ 
        error: 'Slack APIの設定が不足しています。環境変数を確認してください。' 
      });
    }

    console.log(`チャンネル ${targetChannelId} からSlackメッセージを取得します...`);
    
    // チャンネル情報を取得
    let channelName = 'Slack Channel';
    try {
      console.log(`チャンネル ${targetChannelId} からSlackメッセージを取得します...`);
      
      // チャンネル情報を取得
      let channelName = 'Slack Channel';
      try {
        const channelInfo = await slackClient.conversations.info({
          channel: targetChannelId
        });
        channelName = channelInfo.channel.name || 'Slack Channel';
        console.log(`チャンネル名: ${channelName}`);
      } catch (channelError) {
        console.error('チャンネル情報取得エラー:', channelError);
      }
      
      // チャンネル履歴を取得
      const result = await slackClient.conversations.history({
        channel: targetChannelId,
        cursor: cursor,
        limit: 200, // 1回のリクエストで最大200件
        oldest: Math.max(oldestTimestamp, latestMsg || 0), // 3ヶ月前か最新の保存済みメッセージの新しい方を基準にする
        include_all_metadata: true  // リアクション情報などすべてのメタデータを含める
      });
      
      // APIレスポンスのサンプルをログに出力(デバッグ用)
      if (result.messages && result.messages.length > 0) {
        console.log(`APIレスポンスサンプル: 最初のメッセージのフィールド一覧:`, Object.keys(result.messages[0]).join(', '));
        
        // リアクションを含むメッセージがあるか確認
        const msgWithReactions = result.messages.find(msg => msg.reactions && msg.reactions.length > 0);
        if (msgWithReactions) {
          console.log(`リアクションを含むメッセージ例:`, JSON.stringify(msgWithReactions.reactions, null, 2));
        } else {
          console.log(`リアクションを含むメッセージが見つかりませんでした`);
        }
      }
      
      console.log(`${result.messages.length}件のメッセージを取得しました`);

      // メッセージを整形
      const messages = result.messages.map(msg => {
        return {
          text: msg.text,
          user: msg.user,
          ts: msg.ts,
          timestamp: new Date(parseFloat(msg.ts) * 1000).toLocaleString(),
          channelId: targetChannelId,
          channelName: channelName
        };
      });

      // ユーザー情報を取得してメッセージに追加
      const userIds = [...new Set(messages.map(msg => msg.user).filter(id => id && typeof id === 'string' && id !== 'undefined' && id !== 'null'))];
      const userPromises = userIds.map(async userId => {
        try {
          // nullまたは無効なユーザーIDをチェック
          if (!userId || userId === 'null' || userId === 'undefined') {
            console.warn(`無効なユーザーID '${userId}' をスキップします`);
            return { id: userId || 'unknown', name: 'Unknown User', avatar: '' };
          }
          
          const userInfo = await slackClient.users.info({ user: userId });
          if (!userInfo || !userInfo.user) {
            console.warn(`ユーザー情報が見つかりません: ${userId}`);
            return { id: userId, name: 'Unknown User', avatar: '' };
          }
          
          return {
            id: userId,
            name: userInfo.user.real_name || userInfo.user.name || 'Unknown User',
            avatar: userInfo.user.profile?.image_72 || ''
          };
        } catch (error) {
          console.error(`ユーザー情報取得エラー (${userId}):`, error);
          return { id: userId || 'unknown', name: 'Unknown User', avatar: '' };
        }
      });

      const users = await Promise.all(userPromises);
      const userMap = {};
      users.forEach(user => {
        if (user && user.id) {
          userMap[user.id] = user;
        }
      });

      // ユーザー情報をメッセージに追加
      const messagesWithUserInfo = messages.map(msg => {
        const userId = msg.user || 'unknown';
        return {
          ...msg,
          userName: userMap[userId]?.name || 'Unknown User',
          userAvatar: userMap[userId]?.avatar || ''
        };
      });

      res.json(messagesWithUserInfo);
    } catch (error) {
      console.error('Slackメッセージ取得エラー:', error);
      
      if (error.data) {
        console.error('エラー詳細:', JSON.stringify(error.data));
      }
      
      res.status(500).json({ error: 'Slackメッセージの取得に失敗しました' });
    }
  } catch (error) {
    console.error('Slackメッセージ取得の外部エラー:', error);
    res.status(500).json({ error: 'Slackメッセージの取得に失敗しました' });
  }
});

// メッセージ分析のAPI
app.get('/api/analyze-messages', async (req, res) => {
  try {
    const { channelId, userId } = req.query;
    console.log(`分析リクエスト: チャンネル=${channelId}, ユーザー=${userId || '未指定(全ユーザー)'}`);
    
    if (!channelId) {
      return res.status(400).json({ error: 'チャンネルIDが指定されていません' });
    }
    
    // 簡易分析結果を返す
    return res.json({
      success: true,
      source: "simple",
      messageCount: 0,
      filteredMessageCount: 0,
      userCount: 0,
      channelName: "分析機能は現在無効化されています",
      summary: {
        mainTopics: ["機能無効化中"],
        overallSummary: "AI分析機能は現在無効化されています。"
      },
      trendAnalysis: {
        keywords: ["無効化", "メンテナンス中"],
        topicChanges: "分析なし",
        communicationPattern: "分析なし"
      },
      userAnalysis: {},
      insightsAndRecommendations: ["現在、分析機能はメンテナンス中のため利用できません。"]
    });
    
  } catch (error) {
    console.error('メッセージ分析中にエラーが発生しました:', error);
    res.status(500).json({ error: error.message });
  }
});

// APIキー設定画面
app.get('/settings', (req, res) => {
  res.sendFile(path.join(__dirname, 'public', 'settings.html'));
});

// APIキー設定を保存するエンドポイント
app.post('/api/settings', express.json(), (req, res) => {
  try {
    const { anthropicApiKey } = req.body;
    
    if (!anthropicApiKey) {
      return res.status(400).json({ error: 'APIキーは必須です' });
    }
    
    // .renvファイルを読み込む
    let envContent = '';
    try {
      envContent = fs.readFileSync('.renv', 'utf8');
    } catch (error) {
      console.error('.renvファイルの読み込みに失敗しました:', error.message);
      envContent = 'SLACK_BOT_TOKEN=\nSLACK_CHANNEL_ID=\nOPENAI_API_KEY=\nANTHROPIC_API_KEY=\n';
    }
    
    // ANTHROPIC_API_KEYの行を置き換える
    const lines = envContent.split('\n');
    let found = false;
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].startsWith('ANTHROPIC_API_KEY=')) {
        lines[i] = `ANTHROPIC_API_KEY=${anthropicApiKey}`;
        found = true;
        break;
      }
    }
    
    // APIキーの行が見つからなかった場合は追加
    if (!found) {
      lines.push(`ANTHROPIC_API_KEY=${anthropicApiKey}`);
    }
    
    // .renvファイルに書き込む
    fs.writeFileSync('.renv', lines.join('\n'));
    
    // 保存した内容をログに出力(デバッグ用)
    console.log('保存した.renvファイルの内容:');
    console.log(lines.join('\n'));
    
    // 環境変数を更新
    process.env.ANTHROPIC_API_KEY = anthropicApiKey;
    
    // Anthropic clientを再初期化
    try {
      anthropic.apiKey = anthropicApiKey;
      console.log('Anthropic APIキーを更新しました');
    } catch (error) {
      console.error('Anthropic クライアントの再初期化に失敗しました:', error.message);
    }
    
    res.json({ success: true, message: 'APIキーを保存しました' });
  } catch (error) {
    console.error('APIキーの保存に失敗しました:', error.message);
    res.status(500).json({ error: 'APIキーの保存に失敗しました' });
  }
});

// 設定情報を取得するAPI
app.get('/api/settings/info', (req, res) => {
  try {
    // 設定ファイルから情報を取得
    const settings = getSettings();
    
    // Slackトークンの存在確認
    const slackBotTokenSet = !!settings.slackBotToken;
    
    // Slackチャンネルの存在確認
    const slackChannelIdsSet = settings.slackChannelIds && settings.slackChannelIds.length > 0;
    
    // Anthropic APIキーの存在確認
    const anthropicApiKeySet = !!settings.anthropicApiKey;
    
    // OpenAI APIキーの存在確認
    const openaiApiKeySet = !!settings.openaiApiKey;
    
    // センシティブな情報はマスクして返す
    const slackBotTokenMasked = slackBotTokenSet ? 
      maskValue(settings.slackBotToken) : null;
      
    const anthropicApiKeyMasked = anthropicApiKeySet ? 
      maskValue(settings.anthropicApiKey) : null;
      
    const openaiApiKeyMasked = openaiApiKeySet ? 
      maskValue(settings.openaiApiKey) : null;
      
    // キャラクター画像パスを追加
    const characterImage = settings.characterImage || '/images/brave-character.png';
      
    res.json({
      slackBotTokenSet,
      slackChannelIdsSet,
      anthropicApiKeySet,
      openaiApiKeySet,
      slackBotTokenMasked,
      anthropicApiKeyMasked,
      openaiApiKeyMasked,
      slackChannelIds: settings.slackChannelIds || defaultSettings.slackChannelIds || process.env.SLACK_CHANNEL_IDS || '',
      slackBotToken: settings.slackBotToken || defaultSettings.botToken || process.env.SLACK_BOT_TOKEN || '',
      slackSigningSecret: settings.slackSigningSecret || process.env.SLACK_SIGNING_SECRET || '',
      anthropicApiKey: settings.anthropicApiKey || process.env.ANTHROPIC_API_KEY || '',
      openaiApiKey: settings.openaiApiKey || process.env.OPENAI_API_KEY || '',
      aiAnalysisPrompt: settings.aiAnalysisPrompt || defaultAiAnalysisPrompt,
      characterImage
    });
  } catch (error) {
    console.error('設定情報取得エラー:', error);
    res.status(500).json({ error: '設定情報の取得に失敗しました' });
  }
});

// Slack設定を更新するAPI
app.post('/api/settings/slack', express.json(), (req, res) => {
  try {
    const { slackBotToken, slackChannelIds } = req.body;
    
    // 必須項目チェック
    if (!slackChannelIds || !Array.isArray(slackChannelIds) || slackChannelIds.length === 0) {
      return res.status(400).json({ error: '少なくとも1つのチャンネルIDが必要です' });
    }
    
    // .renvファイルを読み込む
    let envContent;
    try {
      envContent = fs.readFileSync('.renv', 'utf8');
    } catch (error) {
      console.error('.renvファイルの読み込みに失敗しました:', error.message);
      envContent = ''; // ファイルが存在しない場合は空文字で初期化
    }
    
    const lines = envContent.split('\n').filter(line => line.trim() !== '');
    
    // 既存の設定を更新または新規追加
    let slackBotTokenFound = false;
    let slackChannelIdsFound = false;
    
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].startsWith('SLACK_BOT_TOKEN=')) {
        if (slackBotToken) {
          lines[i] = `SLACK_BOT_TOKEN=${slackBotToken}`;
        }
        slackBotTokenFound = true;
      }
      
      // 新形式の複数チャンネルIDの設定
      if (lines[i].startsWith('SLACK_CHANNEL_IDS=')) {
        lines[i] = `SLACK_CHANNEL_IDS=${slackChannelIds.join(',')}`;
        slackChannelIdsFound = true;
      }
      
      // 旧形式の単一チャンネルIDを削除(または更新)
      if (lines[i].startsWith('SLACK_CHANNEL_ID=')) {
        if (slackChannelIdsFound) {
          // 複数チャンネルIDがすでに設定されていれば、この行を削除
          lines.splice(i, 1);
          i--; // インデックスを調整
        } else {
          // 複数チャンネルIDが設定されていなければ、デフォルトチャンネルとして設定
          lines[i] = `SLACK_CHANNEL_ID=${slackChannelIds[0]}`;
        }
      }
    }
    
    // 設定が存在しない場合は新規追加
    if (!slackBotTokenFound && slackBotToken) {
      lines.push(`SLACK_BOT_TOKEN=${slackBotToken}`);
    }
    
    if (!slackChannelIdsFound) {
      lines.push(`SLACK_CHANNEL_IDS=${slackChannelIds.join(',')}`);
      // 後方互換性のために最初のチャンネルIDをデフォルトとして設定
      lines.push(`SLACK_CHANNEL_ID=${slackChannelIds[0]}`);
    }
    
    // .renvファイルに書き込む
    fs.writeFileSync('.renv', lines.join('\n'));
    
    // 環境変数にも直接設定
    if (slackBotToken) {
      process.env.SLACK_BOT_TOKEN = slackBotToken;
    }
    process.env.SLACK_CHANNEL_IDS = slackChannelIds.join(',');
    process.env.SLACK_CHANNEL_ID = slackChannelIds[0]; // 後方互換性のためにデフォルトとして設定
    
    // グローバル変数を更新
    global.slackChannelIds = slackChannelIds;
    
    // グローバル変数の更新確認(デバッグ用)
    console.log('グローバル変数slackChannelIdsを更新しました:', JSON.stringify(global.slackChannelIds));
    
    // 成功レスポンス
    res.json({ 
      success: true, 
      message: 'Slack設定を更新しました',
      channelIds: slackChannelIds
    });
  } catch (error) {
    console.error('Slack設定更新エラー:', error);
    res.status(500).json({ error: 'Slack設定の更新に失敗しました: ' + error.message });
  }
});

// Anthropic APIキーを更新するAPI
app.post('/api/settings/anthropic', express.json(), (req, res) => {
  try {
    const { anthropicApiKey } = req.body;
    
    if (!anthropicApiKey) {
      return res.status(400).json({ error: 'APIキーは必須です' });
    }
    
    // .renvファイルを読み込む
    let envContent = '';
    try {
      envContent = fs.readFileSync('.renv', 'utf8');
    } catch (error) {
      console.error('.renvファイルの読み込みに失敗しました:', error.message);
      envContent = 'SLACK_BOT_TOKEN=\nSLACK_CHANNEL_ID=\nOPENAI_API_KEY=\nANTHROPIC_API_KEY=\n';
    }
    
    // ANTHROPIC_API_KEYの行を置き換える
    const lines = envContent.split('\n');
    let found = false;
    for (let i = 0; i < lines.length; i++) {
      if (lines[i].startsWith('ANTHROPIC_API_KEY=')) {
        lines[i] = `ANTHROPIC_API_KEY=${anthropicApiKey}`;
        found = true;
        break;
      }
    }
    
    // APIキーの行が見つからなかった場合は追加
    if (!found) {
      lines.push(`ANTHROPIC_API_KEY=${anthropicApiKey}`);
    }
    
    // .renvファイルに書き込む
    fs.writeFileSync('.renv', lines.join('\n'));
    
    // 保存した内容をログに出力(デバッグ用)
    console.log('保存した.renvファイルの内容:');
    console.log(lines.join('\n'));
    
    // 環境変数を更新
    process.env.ANTHROPIC_API_KEY = anthropicApiKey;
    
    // Anthropic clientを再初期化
    try {
      anthropic.apiKey = anthropicApiKey;
      console.log('Anthropic APIキーを更新しました');
    } catch (error) {
      console.error('Anthropic クライアントの再初期化に失敗しました:', error.message);
    }
    
    res.json({ success: true, message: 'Anthropic APIキーを更新しました' });
  } catch (error) {
    console.error('Anthropic APIキーの更新に失敗しました:', error.message);
    res.status(500).json({ error: 'Anthropic APIキーの更新に失敗しました' });
  }
});

// OpenAI APIキーを更新するAPI
app.post('/api/settings/openai', express.json(), (req, res) => {
  try {
    const { openaiApiKey } = req.body;
    
    if (!openaiApiKey) {
      return res.status(400).json({ error: 'APIキーが指定されていません' });
    }
    
    // 環境変数を更新
    process.env.OPENAI_API_KEY = openaiApiKey;
    
    // .env ファイルがある場合は更新
    const envPath = path.join(__dirname, '.renv');
    
    if (fs.existsSync(envPath)) {
      // 既存の .env ファイルを読み込む
      const envFileContent = fs.readFileSync(envPath, 'utf8');
      
      // OPENAI_API_KEY の行があれば更新、なければ追加
      let updatedContent;
      if (envFileContent.match(/OPENAI_API_KEY=/)) {
        updatedContent = envFileContent.replace(/OPENAI_API_KEY=.*/, `OPENAI_API_KEY=${openaiApiKey}`);
      } else {
        updatedContent = envFileContent + `\nOPENAI_API_KEY=${openaiApiKey}`;
      }
      
      // 更新した内容で .env ファイルを上書き
      fs.writeFileSync(envPath, updatedContent);
      
      console.log('OpenAI APIキーが設定されました');
    } else {
      console.warn('.env ファイルが存在しないため、メモリ上のみで環境変数を更新しました');
    }
    
    res.json({ success: true });
  } catch (error) {
    console.error('OpenAI APIキー設定中にエラーが発生しました:', error);
    res.status(500).json({ error: error.message });
  }
});

// AI分析プロンプト設定エンドポイント
app.post('/api/settings/ai-prompt', express.json(), (req, res) => {
  try {
    const { aiAnalysisPrompt } = req.body;
    
    // 空の場合はデフォルト値に戻す
    if (!aiAnalysisPrompt) {
      currentAiAnalysisPrompt = defaultAiAnalysisPrompt;
      
      // 設定を更新して保存
      const settings = getSettings();
      settings.aiAnalysisPrompt = defaultAiAnalysisPrompt;
      saveSettings(settings);
      
      return res.json({
        success: true,
        message: 'AI分析プロンプトがデフォルト値に戻されました'
      });
    }
    
    // プロンプトを更新
    currentAiAnalysisPrompt = aiAnalysisPrompt;
    
    // 設定を更新して保存
    const settings = getSettings();
    settings.aiAnalysisPrompt = aiAnalysisPrompt;
    saveSettings(settings);
    
    console.log('AI分析プロンプトが更新されました');
    res.json({
      success: true,
      message: 'AI分析プロンプトが正常に更新されました'
    });
  } catch (error) {
    console.error('AI分析プロンプト設定中にエラーが発生しました:', error);
    res.status(500).json({ error: error.message });
  }
});

// AI分析プロンプトをリセットするエンドポイント
app.post('/api/settings/reset-ai-prompt', express.json(), (req, res) => {
  try {
    // デフォルトプロンプトに戻す
    currentAiAnalysisPrompt = defaultAiAnalysisPrompt;
    
    // 設定を更新して保存
    const settings = getSettings();
    settings.aiAnalysisPrompt = defaultAiAnalysisPrompt;
    saveSettings(settings);
    
    console.log('AI分析プロンプトがリセットされました');
    
    // デフォルトプロンプトをログに出力して確認
    console.log('デフォルトプロンプト (最初の50文字):', defaultAiAnalysisPrompt.substring(0, 50) + '...');
    
    // レスポンスを返す
    res.json({
      success: true,
      message: 'AI分析プロンプトがデフォルト値に正常にリセットされました',
      aiAnalysisPrompt: defaultAiAnalysisPrompt // デフォルトプロンプトをレスポンスに含める
    });
  } catch (error) {
    console.error('AI分析プロンプトリセット中にエラーが発生しました:', error);
    res.status(500).json({ error: error.message });
  }
});

// データリセットエンドポイント
app.post('/api/settings/reset-data', express.json(), (req, res) => {
  try {
    console.log('データベースのリセットを開始します...');
    
    // 各テーブルのデータを削除
    db.run('DELETE FROM slack_messages');
    db.run('DELETE FROM slack_thread_replies');
    db.run('DELETE FROM slack_reactions');
    db.run('DELETE FROM slack_channels');
    db.run('DELETE FROM daily_pickup_user');
    db.run('DELETE FROM user_analysis');
    
    // シーケンスリセット (SQLiteの場合)
    db.run('DELETE FROM sqlite_sequence WHERE name IN (\'slack_messages\', \'slack_thread_replies\', \'slack_reactions\', \'user_analysis\', \'daily_pickup_user\')');
    
    console.log('データベースのリセットが完了しました');
    
    res.json({ 
      success: true, 
      message: 'データベースが正常にリセットされました。ローカルストレージの同期日時情報も削除されます。'
    });
  } catch (error) {
    console.error('データベースリセットエラー:', error);
    res.status(500).json({ error: 'データベースのリセットに失敗しました: ' + error.message });
  }
});

// センシティブな値をマスクする関数
function maskValue(value) {
  if (!value) return null;
  
  if (value.length > 12) {
    return value.substring(0, 6) + '...' + value.substring(value.length - 4);
  } else {
    return '***' + value.substring(value.length - 4);
  }
}

// キャラクター画像保存先のディレクトリ設定
const characterStorage = multer.diskStorage({
  destination: function(req, file, cb) {
    const uploadDir = path.join(__dirname, 'public', 'images');
    // ディレクトリが存在しない場合は作成
    if (!fs.existsSync(uploadDir)) {
      fs.mkdirSync(uploadDir, { recursive: true });
    }
    cb(null, uploadDir);
  },
  filename: function(req, file, cb) {
    // ファイル名を brave-character + 拡張子に設定(上書き)
    const extname = path.extname(file.originalname);
    cb(null, 'brave-character-custom' + extname);
  }
});

// キャラクター画像アップロード用のmulterインスタンス
const uploadCharacter = multer({
  storage: characterStorage,
  limits: {
    fileSize: 2 * 1024 * 1024 // 2MB制限
  },
  fileFilter: function(req, file, cb) {
    // 画像ファイルのみ許可
    const filetypes = /jpeg|jpg|png|gif|svg/;
    const mimetype = filetypes.test(file.mimetype);
    const extname = filetypes.test(path.extname(file.originalname).toLowerCase());
    
    if (mimetype && extname) {
      return cb(null, true);
    }
    cb(new Error('画像ファイル(jpeg, jpg, png, gif, svg)のみアップロードできます'));
  }
});

// キャラクター画像アップロードAPI
app.post('/api/settings/upload-character', uploadCharacter.single('character-image'), (req, res) => {
  try {
    if (!req.file) {
      return res.status(400).json({ success: false, error: 'ファイルがアップロードされていません' });
    }
    
    console.log(`キャラクター画像をアップロードしました: ${req.file.filename}`);
    
    // キャラクターファイル情報を設定ファイルに保存
    const settings = getSettings();
    settings.characterImage = `/images/${req.file.filename}`;
    const saved = saveSettings(settings);
    
    if (!saved) {
      throw new Error('設定の保存に失敗しました');
    }
    
    res.json({ 
      success: true, 
      message: 'キャラクター画像が正常にアップロードされました',
      imagePath: `/images/${req.file.filename}`
    });
  } catch (error) {
    console.error('キャラクター画像アップロードエラー:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// キャラクター画像をデフォルトに戻すAPI
app.post('/api/settings/reset-character', express.json(), (req, res) => {
  try {
    // カスタム画像があれば削除
    const customImagePath = path.join(__dirname, 'public', 'images', 'brave-character-custom.png');
    const customJpgPath = path.join(__dirname, 'public', 'images', 'brave-character-custom.jpg');
    const customSvgPath = path.join(__dirname, 'public', 'images', 'brave-character-custom.svg');
    const customGifPath = path.join(__dirname, 'public', 'images', 'brave-character-custom.gif');
    
    if (fs.existsSync(customImagePath)) {
      fs.unlinkSync(customImagePath);
      console.log('カスタムキャラクター画像(PNG)を削除しました');
    }
    
    if (fs.existsSync(customJpgPath)) {
      fs.unlinkSync(customJpgPath);
      console.log('カスタムキャラクター画像(JPG)を削除しました');
    }
    
    if (fs.existsSync(customSvgPath)) {
      fs.unlinkSync(customSvgPath);
      console.log('カスタムキャラクター画像(SVG)を削除しました');
    }
    
    if (fs.existsSync(customGifPath)) {
      fs.unlinkSync(customGifPath);
      console.log('カスタムキャラクター画像(GIF)を削除しました');
    }
    
    // 設定からカスタム画像設定を削除
    const settings = getSettings();
    delete settings.characterImage;
    saveSettings(settings);
    
    res.json({ 
      success: true, 
      message: 'キャラクター画像がデフォルトにリセットされました'
    });
  } catch (error) {
    console.error('キャラクターリセットエラー:', error);
    res.status(500).json({ success: false, error: error.message });
  }
});

// 設定情報取得API
// ... existing code ...

// 今日のイチ推しBraverを選出するメインの処理
async function resetAndSelectDailyPickupUser() {
  try {
    // 日本時間で今日の日付を取得
    const today = getJSTDateString();
    console.log(`今日(${today})のイチ推しbraverデータをリセットします`);
    
    // 今日のピックアップデータを削除
    await new Promise((resolve, reject) => {
      db.run('DELETE FROM daily_pickup_user WHERE pickup_date = ?', [today], function(err) {
        if (err) {
          console.error('イチ推しbraverデータのリセットに失敗:', err);
          reject(err);
          return;
        }
        
        console.log(`今日のイチ推しbraverをリセットしました: ${this.changes}件のデータを削除`);
        resolve();
      });
    });
    
    // イチ推しBraverを選出
    await selectDailyPickupUser();
    
    console.log('イチ推しbraverの再選出が完了しました');
    return true;
  } catch (error) {
    console.error('イチ推しbraver選出エラー:', error);
    return false;
  }
}

// selectDailyPickupUser関数
async function selectDailyPickupUser() {
  console.log('イチ推しbraverを選出中...');
  
  try {
    // 今日の日付 (JST)
    const today = getJSTDateString();
    
    // テーブル構造を確認
    console.log('daily_pickup_userテーブルの構造を確認しています...');
    const tableInfo = await new Promise((resolve, reject) => {
      db.all('PRAGMA table_info(daily_pickup_user)', (err, rows) => {
        if (err) {
          reject(err);
          return;
        }
        resolve(rows);
      });
    });
    
    // カラム名のリストを表示
    const columns = tableInfo.map(col => col.name).join(', ');
    console.log(`daily_pickup_userの既存カラム: ${columns}`);
    
    // 本日のデータをリセット
    console.log(`本日のイチ推しbraverデータをリセットします: ${today}`);
    await new Promise((resolve, reject) => {
      db.run('DELETE FROM daily_pickup_user WHERE pickup_date = ?', [today], function(err) {
        if (err) {
          console.error('イチ推しbraverデータのリセットに失敗:', err);
          reject(err);
          return;
        }
        console.log(`本日のイチ推しbraverデータをリセットしました: ${this.changes}件削除`);
        resolve();
      });
    });
    
    // 実際のデータを取得するSQLクエリ
    const query = `
      SELECT 
        u.user_id, 
        u.name, 
        u.real_name, 
        u.display_name, 
        u.avatar,
        (SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id) AS message_count,
        (SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = u.user_id) AS reply_count,
        (
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')
        ) AS attendance_count,
        (
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')
        ) AS office_count,
        (
          SELECT COUNT(*) FROM slack_messages
          WHERE user_id = u.user_id
          AND length(text) >= 50
        ) AS report_count,
        (
          SELECT COUNT(*) FROM slack_reactions
          WHERE user_id = u.user_id
        ) AS sent_reaction_count,
        (
          SELECT COUNT(*) FROM slack_reactions r
          JOIN slack_messages m ON r.message_id = m.message_id
          WHERE m.user_id = u.user_id
        ) AS received_reaction_count
      FROM 
        slack_users u
      WHERE 
        u.is_bot = 0
        AND (
          SELECT COUNT(*) FROM slack_reactions r
          JOIN slack_messages m ON r.message_id = m.message_id
          WHERE m.user_id = u.user_id
        ) >= 30
      ORDER BY 
        RANDOM()
      LIMIT 3
    `;
    
    console.log('リアクション数30以上のユーザーからランダムに3人選出するクエリを実行します');
    
    // ユーザーを取得してイチ推しbraverに登録
    const users = await new Promise((resolve, reject) => {
      db.all(query, [], (err, rows) => {
        if (err) {
          console.error('ユーザー取得エラー:', err);
          reject(err);
          return;
        }
        resolve(rows);
      });
    });
    
    console.log(`イチ推しbraver候補: ${users.length}人`);
    
    // 各ユーザーをイチ推しbraverテーブルに登録
    for (const row of users) {
      try {
        const userName = row.display_name || row.real_name || row.name || 'Unknown';
        console.log(`イチ推しbraverに登録: ${userName} (${row.user_id}) - メッセージ数: ${row.message_count}, 返信数: ${row.reply_count}, リアクション数: ${row.received_reaction_count}`);
        
        // アバターURLを高解像度版に変換
        let avatarUrl = row.avatar;
        if (avatarUrl && avatarUrl.includes('_72')) {
          avatarUrl = avatarUrl.replace('_72', '_512');
          console.log(`アバター画像URL変換: ${row.avatar} -> ${avatarUrl}`);
        }
        
        // AI分析テキストを生成
        let analysis = '';
        try {
          // ユーザーの最新のメッセージを取得(最大10件)
          const userMessages = await new Promise((resolve, reject) => {
            db.all(
              `SELECT text, timestamp FROM slack_messages WHERE user_id = ? ORDER BY timestamp DESC LIMIT 10`,
              [row.user_id],
              (err, messages) => {
                if (err) {
                  console.error(`ユーザーメッセージ取得エラー (${row.user_id}):`, err);
                  reject(err);
                } else {
                  resolve(messages);
                }
              }
            );
          });
          
          // メッセージがあればAI分析を実行
          if (userMessages && userMessages.length > 0) {
            console.log(`${userName}のメッセージデータをAIで分析します (${userMessages.length}件)...`);
            
            // メッセージテキストを結合
            const messagesText = userMessages.map(m => m.text).join('\n\n');
            
            // AI分析のプロンプト
            const prompt = currentAiAnalysisPrompt + `\n\nユーザー名: ${userName}\nメッセージ数: ${row.message_count}件\n返信数: ${row.reply_count}件\n出勤数: ${row.attendance_count}日\nリアル出社: ${row.office_count}日\nスタンプ数: ${row.received_reaction_count}個\n\n【メッセージ一覧】\n${messagesText}\n\nこのユーザーの特徴を簡潔にまとめて、改行を最小限にし、段落間の空白行なしで350文字以内で分析してください。`;
            
            try {
              // Anthropic APIを呼び出し
              if (anthropic && process.env.ANTHROPIC_API_KEY) {
                const response = await anthropic.messages.create({
                  model: "claude-3-haiku-20240307",
                  max_tokens: 1000,
                  messages: [
                    { role: "user", content: prompt }
                  ],
                  temperature: 0.7,
                });
                
                // 改行や段落間の空白行を取り除いて分析テキストを整形
                analysis = response.content[0].text
                  .replace(/\n\s*\n/g, '\n') // 空白行を削除
                  .replace(/\n+/g, ' ') // 残りの改行を空白に置換
                  .trim();
                  
                console.log(`AI分析完了 (${analysis.length}文字)`);
              } else {
                throw new Error('Anthropic APIが設定されていません');
              }
            } catch (aiError) {
              // AI分析エラー時はフォールバック
              console.error(`AI分析エラー (${row.user_id}):`, aiError);
              const messageCount = row.message_count || 0;
              const replyCount = row.reply_count || 0;
              const attendanceCount = row.attendance_count || 0;
              const officeCount = row.office_count || 0;
              const receivedReactionCount = row.received_reaction_count || 0;
              
              analysis = `${userName}さんは、投稿数${messageCount}件、返信数${replyCount}件と活発に活動されています。出勤数は${attendanceCount}日、リアル出社は${officeCount}日です。チーム内での積極的なコミュニケーションと協力的な姿勢が特徴的です。スタンプ数${receivedReactionCount}個の人気者です!`;
            }
          } else {
            // メッセージがない場合はデフォルトの分析
            console.log(`${userName}のメッセージデータが見つかりません。デフォルトの分析を使用します。`);
            const messageCount = row.message_count || 0;
            const replyCount = row.reply_count || 0;
            const attendanceCount = row.attendance_count || 0;
            const officeCount = row.office_count || 0;
            const receivedReactionCount = row.received_reaction_count || 0;
            
            analysis = `${userName}さんは、投稿数${messageCount}件、返信数${replyCount}件と活発に活動されています。出勤数は${attendanceCount}日、リアル出社は${officeCount}日です。チーム内での積極的なコミュニケーションと協力的な姿勢が特徴的です。スタンプ数${receivedReactionCount}個の人気者です!`;
          }
        } catch (analysisError) {
          // エラー時はデフォルトの分析を使用
          console.error(`分析処理エラー (${row.user_id}):`, analysisError);
          const messageCount = row.message_count || 0;
          const replyCount = row.reply_count || 0;
          const attendanceCount = row.attendance_count || 0;
          const officeCount = row.office_count || 0;
          const receivedReactionCount = row.received_reaction_count || 0;
          
          analysis = `${userName}さんは、投稿数${messageCount}件、返信数${replyCount}件と活発に活動されています。出勤数は${attendanceCount}日、リアル出社は${officeCount}日です。チーム内での積極的なコミュニケーションと協力的な姿勢が特徴的です。スタンプ数${receivedReactionCount}個の人気者です!`;
        }
        
        // データを保存
        await new Promise((resolve, reject) => {
          const now = new Date();
          const currentTimestamp = now.toISOString().replace('T', ' ').substring(0, 19);
          
          db.run(
            'INSERT INTO daily_pickup_user (user_id, name, real_name, display_name, avatar, message_count, reply_count, attendance_count, office_count, report_count, sent_reaction_count, received_reaction_count, analysis, pickup_date, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
            [
              row.user_id, 
              row.name, 
              row.real_name, 
              row.display_name,
              avatarUrl,
              row.message_count || 0,
              row.reply_count || 0,
              row.attendance_count || 0,
              row.office_count || 0,
              row.report_count || 0,
              row.sent_reaction_count || 0,
              row.received_reaction_count || 0,
              analysis,
              today,
              currentTimestamp
            ],
            (err) => {
              if (err) {
                console.error('イチ推しbraver登録エラー:', err);
                reject(err);
              } else {
                resolve();
              }
            }
          );
        });
      } catch (userError) {
        console.error(`ユーザー登録エラー (${row.user_id}):`, userError);
      }
    }
    
    console.log(`本日のイチ推しbraver選出完了: ${users.length}人`);
    
  } catch (error) {
    console.error('イチ推しbraver選出エラー:', error);
  }
}

// Slackデータを同期するAPI
app.get('/api/sync-slack-data', async (req, res) => {
  try {
    if (!slackToken || !slackChannelIds.length) {
      return res.status(500).json({ 
        error: 'Slack APIの設定が不足しています。環境変数を確認してください。' 
      });
    }

    console.log('Slackデータ同期を開始...');
    
    // 同期するチャンネルの指定(特定チャンネルまたは全チャンネル)
    const requestedChannelId = req.query.channelId;
    let channelsToSync = [];
    
    if (requestedChannelId) {
      // 特定のチャンネルが指定された場合
      channelsToSync = [requestedChannelId];
      console.log(`指定されたチャンネル ${requestedChannelId} のみを同期します`);
    } else {
      // すべてのチャンネルを同期
      channelsToSync = global.slackChannelIds || slackChannelIds;
      console.log(`設定されている全チャンネル (${channelsToSync.length}件) を同期します: ${channelsToSync.join(', ')}`);
    }
    
    // チャンネルごとの同期結果を保存する配列
    const syncResults = [];
    
    // 各チャンネルを順番に処理
    for (const channelId of channelsToSync) {
      console.log(`チャンネル ${channelId} の同期を開始します...`);
      
      try {
        // チャンネル情報を取得して保存
        let channelName = 'Unknown Channel';
        let channelInfo;
        
        try {
          channelInfo = await slackClient.conversations.info({
            channel: channelId
          });
          
          channelName = channelInfo.channel.name || 'Unknown Channel';
          console.log(`チャンネル情報取得: ${channelName} (${channelId})`);
          
          // チャンネル情報をDBに保存
          const channelStmt = db.prepare(`
            INSERT OR REPLACE INTO slack_channels (channel_id, name)
            VALUES (?, ?)
          `);
          
          channelStmt.run(channelId, channelName);
          channelStmt.finalize();
          console.log(`チャンネル情報を保存: ${channelName}`);
        } catch (channelError) {
          console.error(`チャンネル ${channelId} の情報取得エラー:`, channelError);
          continue; // このチャンネルをスキップして次へ
        }
        
        // 過去3ヶ月のタイムスタンプを計算
        const threeMonthsAgo = new Date();
        threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3); // 3ヶ月前に変更
        const oldestTimestamp = threeMonthsAgo.getTime() / 1000; // UnixタイムスタンプはUNIX時間(秒)
        console.log(`3ヶ月前の日時: ${threeMonthsAgo.toLocaleString()}, タイムスタンプ: ${oldestTimestamp}`);
        
        // DBに保存済みの最新タイムスタンプを取得
        const latestMsg = await new Promise((resolve, reject) => {
          db.get('SELECT MAX(timestamp) AS latest FROM slack_messages WHERE channel_id = ?', [channelId], (err, row) => {
            if (err) {
              console.error('最新メッセージ取得エラー:', err);
              resolve(null);
            } else {
              resolve(row?.latest || null);
            }
          });
        });
        
        console.log(`既存の最新メッセージタイムスタンプ: ${latestMsg || 'なし'}`);
        
        // 保存済みのメッセージIDを取得(重複チェック用)
        const existingMessageIds = new Set();
        await new Promise((resolve, reject) => {
          db.each('SELECT message_id FROM slack_messages WHERE channel_id = ?', [channelId], 
            (err, row) => {
              if (!err && row && row.message_id) {
                existingMessageIds.add(row.message_id);
              }
            },
            (err) => {
              if (err) {
                console.error('既存メッセージID取得エラー:', err);
              } else {
                console.log(`${existingMessageIds.size}件の既存メッセージIDを取得`);
              }
              resolve();
            }
          );
        });
        
        // 保存済みのスレッド返信IDを取得(重複チェック用)
        const existingReplyIds = new Set();
        await new Promise((resolve, reject) => {
          db.each('SELECT message_id FROM slack_thread_replies WHERE channel_id = ?', [channelId], 
            (err, row) => {
              if (!err && row && row.message_id) {
                existingReplyIds.add(row.message_id);
              }
            },
            (err) => {
              if (err) {
                console.error('既存スレッド返信ID取得エラー:', err);
              } else {
                console.log(`${existingReplyIds.size}件の既存スレッド返信IDを取得`);
              }
              resolve();
            }
          );
        });
        
        // メッセージ保存のプリペアドステートメント
        const msgStmt = db.prepare(`
          INSERT OR IGNORE INTO slack_messages (message_id, user_id, channel_id, text, timestamp)
          VALUES (?, ?, ?, ?, ?)
        `);
        
        // スレッド返信保存のプリペアドステートメント
        const replyStmt = db.prepare(`
          INSERT OR IGNORE INTO slack_thread_replies (message_id, parent_message_id, user_id, channel_id, text, timestamp, reply_id, thread_ts, channel_name)
          VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        `);
        
        // 過去のメッセージ取得(初回は過去3ヶ月分、それ以降は差分のみ)
        let cursor = null;
        let totalMessages = 0;
        let newMessages = 0;
        let skippedMessages = 0;
        let totalReplies = 0;
        let newReplies = 0;
        let skippedReplies = 0;
        let hasMore = true;
        let oldestMsgDate = null;
        
        while (hasMore) {
          try {
            // チャンネル履歴を取得
            const result = await slackClient.conversations.history({
              channel: channelId,
              cursor: cursor,
              limit: 200, // 1回のリクエストで最大200件
              oldest: Math.max(oldestTimestamp, latestMsg || 0), // 3ヶ月前か最新の保存済みメッセージの新しい方を基準にする
              include_all_metadata: true // リアクション情報などすべてのメタデータを含める
            });
            
            // APIレスポンスのサンプルをログに出力(デバッグ用)
            if (result.messages && result.messages.length > 0) {
              console.log(`APIレスポンスサンプル: 最初のメッセージのフィールド一覧:`, Object.keys(result.messages[0]).join(', '));
              
              // リアクションを含むメッセージがあるか確認
              const msgWithReactions = result.messages.find(msg => msg.reactions && msg.reactions.length > 0);
              if (msgWithReactions) {
                console.log(`リアクションを含むメッセージ例:`, JSON.stringify(msgWithReactions.reactions, null, 2));
              } else {
                console.log(`リアクションを含むメッセージが見つかりませんでした`);
              }
            }
            
            totalMessages += result.messages.length;
            
            if (result.messages.length === 0) {
              console.log('新しいメッセージはありません');
              hasMore = false;
              break;
            }
            
            // 最も古いメッセージの日時を記録(デバッグ用)
            if (result.messages.length > 0) {
              const oldestMsg = result.messages[result.messages.length - 1];
              oldestMsgDate = new Date(parseFloat(oldestMsg.ts) * 1000).toLocaleString();
            }
            
            // DBにメッセージを保存
            for (const msg of result.messages) {
              if (msg.user && msg.ts && msg.text) {
                // 既に保存済みのメッセージはスキップ
                if (existingMessageIds.has(msg.ts)) {
                  skippedMessages++;
                } else {
                  // 新しいメッセージをDBに保存
                  msgStmt.run(msg.ts, msg.user, channelId, msg.text, msg.ts);
                  newMessages++;
                  existingMessageIds.add(msg.ts); // 重複チェック用セットに追加
                }
                
                // スレッド情報のデバッグ
                if (msg.thread_ts) {
                  console.log(`スレッド検出: message_id=${msg.ts}, thread_ts=${msg.thread_ts}, reply_count=${msg.reply_count || 0}`);
                }
                
                // スレッド返信がある場合、それらを取得して保存
                if (msg.thread_ts && msg.reply_count && msg.reply_count > 0) {
                  try {
                    console.log(`スレッド返信を取得します: thread_ts=${msg.thread_ts}, reply_count=${msg.reply_count}`);
                    
                    // このメッセージのスレッド返信を取得
                    const repliesResult = await slackClient.conversations.replies({
                      channel: channelId,
                      ts: msg.thread_ts,
                      limit: 200 // 一度に取得する最大数
                    });
                    
                    console.log(`スレッド返信取得結果: ${repliesResult.messages.length}件`);
                    
                    // 最初のメッセージ(親メッセージ)をスキップし、返信のみを処理
                    const replies = repliesResult.messages.slice(1);
                    totalReplies += replies.length;
                    
                    // 各返信をDBに保存
                    for (const reply of replies) {
                      if (reply.user && reply.ts && reply.text) {
                        // 既に保存済みの返信はスキップ
                        if (existingReplyIds.has(reply.ts)) {
                          skippedReplies++;
                        } else {
                          // 新しい返信をDBに保存
                          replyStmt.run(reply.ts, msg.thread_ts, reply.user, channelId, reply.text, reply.ts, reply.reply_id, reply.thread_ts, reply.channel_name);
                          newReplies++;
                          existingReplyIds.add(reply.ts); // 重複チェック用セットに追加
                        }
                      }
                    }
                  } catch (replyError) {
                    console.error(`スレッド返信の取得エラー: thread_ts=${msg.thread_ts}`, replyError);
                  }
                }
                
                // reactions.get APIを使用してリアクション情報を取得
                try {
                  console.log(`メッセージID: ${msg.ts} のリアクション情報を取得中...`);
                  
                  // リアクション情報を直接取得
                  const reactionInfo = await slackClient.reactions.get({
                    channel: channelId,
                    timestamp: msg.ts,
                    full: true
                  });
                  
                  // APIレスポンスの確認(デバッグ用)
                  console.log(`reactions.get APIレスポンス:`, Object.keys(reactionInfo).join(', '));
                  
                  // リアクション情報があれば処理
                  if (reactionInfo.message && reactionInfo.message.reactions && reactionInfo.message.reactions.length > 0) {
                    console.log(`リアクション検出: message_id=${msg.ts}, reaction_count=${reactionInfo.message.reactions.length}`);
                    console.log(`リアクション詳細:`, JSON.stringify(reactionInfo.message.reactions, null, 2));
                    
                    // リアクション情報をDBに保存するステートメント
                    const reactionStmt = db.prepare(`
                      INSERT OR IGNORE INTO slack_reactions (message_id, user_id, name, timestamp)
                      VALUES (?, ?, ?, ?)
                    `);
                    
                    let totalSavedReactions = 0;
                    
                    // 各リアクションを処理
                    for (const reaction of reactionInfo.message.reactions) {
                      if (reaction.name && reaction.users && reaction.users.length > 0) {
                        console.log(`リアクション「${reaction.name}」のユーザー数: ${reaction.users.length}`);
                        
                        for (const userId of reaction.users) {
                          try {
                            // リアクションをDBに保存(インラインでSQLを実行)
                            db.run(`
                              INSERT OR IGNORE INTO slack_reactions (message_id, user_id, name, timestamp)
                              VALUES (?, ?, ?, ?)
                            `, [msg.ts, userId, reaction.name, msg.ts], function(err) {
                              if (err) {
                                console.error(`リアクション保存エラー: ${err.message}`);
                              } else {
                                console.log(`リアクション保存成功: message_id=${msg.ts}, user=${userId}, emoji=${reaction.name}`);
                                totalSavedReactions++;
                              }
                            });
                          } catch (insertError) {
                            console.error(`リアクション挿入エラー: ${insertError.message}`);
                          }
                        }
                      } else {
                        console.log(`警告: リアクション「${reaction.name}」にユーザー情報がありません`);
                      }
                    }
                    
                    // 保存統計の出力
                    console.log(`メッセージID: ${msg.ts} について ${totalSavedReactions}件のリアクションを保存しました`);
                    
                    // ステートメントをクローズ
                    
                    // APIレート制限を避けるため少し待機
                    await new Promise(resolve => setTimeout(resolve, 200));
                  } else {
                    console.log(`メッセージID: ${msg.ts} にはリアクションがありません`);
                    if (reactionInfo.message) {
                      console.log(`メッセージフィールド:`, Object.keys(reactionInfo.message).join(', '));
                    } else {
                      console.log(`メッセージフィールドが存在しません`);
                    }
                  }
                } catch (reactionError) {
                  console.error(`リアクション取得エラー (message_id=${msg.ts}):`, reactionError);
                }
              }
            }
            
            // 次のページがあるか確認
            cursor = result.response_metadata?.next_cursor;
            hasMore = Boolean(cursor);
            
            console.log(`次のカーソル: ${cursor || 'なし'}`);
            
          } catch (historyError) {
            console.error(`チャンネル履歴の取得エラー: ${channelId}`, historyError);
            hasMore = false;
          }
        }
        
        // プリペアドステートメントをクローズ
        msgStmt.finalize();
        replyStmt.finalize();
        
        console.log(`チャンネル ${channelName} (${channelId}) の同期が完了しました。メッセージ: ${newMessages}件(スキップ: ${skippedMessages}件)、返信: ${newReplies}件(スキップ: ${skippedReplies}件)`);
        
        // チャンネルごとの結果を保存
        syncResults.push({
          channelId,
          channelName,
          newMessages,
          skippedMessages,
          newReplies,
          skippedReplies,
          totalMessages,
          totalReplies,
          oldestMsgDate
        });
        
      } catch (channelSyncError) {
        console.error(`チャンネル ${channelId} の同期エラー:`, channelSyncError);
        syncResults.push({
          channelId,
          error: channelSyncError.message
        });
      }
    }
    
    // イチ推しbraverのリセットと再選出
    try {
      await resetAndSelectDailyPickupUser();
    } catch (pickupError) {
      console.error('同期後のイチ推しbraver選出エラー:', pickupError);
    }
    
    return res.json({
      success: true,
      syncedChannels: syncResults,
      channelCount: syncResults.length,
      period: `${getJSTDate().toLocaleDateString('ja-JP')} までの3ヶ月分`,
      message: "スタンプ情報も含めて全データを同期しました"
    });
  } catch (error) {
    console.error('Slackデータ同期エラー:', error);
    return res.status(500).json({ error: `同期処理中にエラーが発生しました: ${error.message}` });
  }
});

// Slackユーザー一覧を取得するAPI
app.get('/api/users', async (req, res) => {
  try {
    const query = `
      SELECT 
        u.user_id, 
        u.name, 
        u.real_name, 
        u.display_name, 
        u.avatar,
        u.is_bot,
        (SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id) AS message_count,
        (SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = u.user_id) AS reply_count,
        (
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')
        ) AS attendance_count,
        (
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')
        ) AS office_count,
        (
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND length(text) >= 50
        ) AS weekly_report_count,
        (
          SELECT COUNT(*) FROM slack_reactions
          WHERE user_id = u.user_id
        ) AS sent_reaction_count,
        (
          SELECT COUNT(*) FROM slack_reactions r
          JOIN slack_messages m ON r.message_id = m.message_id
          WHERE m.user_id = u.user_id
        ) AS received_reaction_count
      FROM 
        slack_users u
      WHERE 
        u.is_bot = 0
      ORDER BY 
        message_count DESC
    `;
    
    db.all(query, [], (err, users) => {
      if (err) {
        console.error('ユーザー一覧取得エラー:', err);
        return res.status(500).json({ error: 'ユーザーデータの取得に失敗しました' });
      }
      
      // リアル出社率を計算
      users.forEach(user => {
        const officeCount = user.office_count || 0;
        const attendanceCount = user.attendance_count || 0;
        user.office_ratio = attendanceCount > 0 ? Math.round((officeCount / attendanceCount) * 100) : 0;
      });
      
      res.json(users);
    });
  } catch (error) {
    console.error('ユーザー一覧取得エラー:', error);
    res.status(500).json({ error: 'ユーザーデータの取得に失敗しました' });
  }
});

// ユーザー詳細情報を取得する代替API
app.get('/api/user-detail/:userId', (req, res) => {
  try {
    const userId = req.params.userId;
    console.log(`ユーザー詳細情報リクエスト: ${userId}`);
    
    // 基本的なユーザー情報を取得するシンプルなクエリ
    const query = `
      SELECT 
        user_id, 
        name, 
        real_name, 
        display_name, 
        avatar,
        is_bot
      FROM 
        slack_users
      WHERE 
        user_id = ?
    `;
    
    db.get(query, [userId], (err, user) => {
      if (err) {
        console.error('ユーザー詳細情報取得エラー:', err);
        return res.status(500).json({ error: 'ユーザー詳細情報の取得に失敗しました' });
      }
      
      if (!user) {
        return res.status(404).json({ error: 'ユーザーが見つかりません' });
      }
      
      // 追加情報を取得
      const countQuery = `
        SELECT 
          (SELECT COUNT(*) FROM slack_messages WHERE user_id = ?) AS message_count,
          (SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = ?) AS reply_count,
          (SELECT COUNT(*) FROM slack_messages WHERE user_id = ? AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')) AS attendance_count,
          (SELECT COUNT(*) FROM slack_messages WHERE user_id = ? AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')) AS office_count,
          (SELECT COUNT(*) FROM slack_messages WHERE user_id = ? AND LENGTH(text) >= 50) AS weekly_report_count,
          (SELECT COUNT(*) FROM slack_reactions WHERE user_id = ?) AS sent_reaction_count,
          (SELECT COUNT(*) FROM slack_reactions r JOIN slack_messages m ON r.message_id = m.message_id WHERE m.user_id = ?) AS received_reaction_count
      `;
      
      db.get(countQuery, [userId, userId, userId, userId, userId, userId, userId], (countErr, counts) => {
        if (countErr) {
          console.error('ユーザーカウント情報取得エラー:', countErr);
          // エラーがあっても基本情報は返す
        } else if (counts) {
          // カウント情報をユーザー情報に追加
          Object.assign(user, counts);
        }
        
        // 週報・発言リストを取得(50文字以上のメッセージ)
        const reportsQuery = `
          SELECT 
            m.message_id,
            m.text as text_preview,
            m.timestamp as date,
            c.name as channel_name,
            m.channel_id
          FROM 
            slack_messages m
            LEFT JOIN slack_channels c ON m.channel_id = c.channel_id
          WHERE 
            m.user_id = ? 
            AND LENGTH(m.text) >= 50
          ORDER BY 
            m.timestamp DESC
          LIMIT 20
        `;
        
        db.all(reportsQuery, [userId], (reportsErr, reports) => {
          if (reportsErr) {
            console.error('週報・発言リスト取得エラー:', reportsErr);
            user.reports = [];
          } else {
            // 日付をフォーマット
            reports.forEach(report => {
              if (report.date) {
                report.date = new Date(parseFloat(report.date) * 1000);
              }
              // テキストを制限(長すぎる場合)
              if (report.text_preview && report.text_preview.length > 200) {
                report.text_preview = report.text_preview.substring(0, 200) + '...';
              }
            });
            user.reports = reports || [];
          }
          
          // 返信リストを取得
          const repliesQuery = `
            SELECT 
              r.message_id,
              r.text as text_preview,
              r.timestamp as date,
              r.parent_message_id,
              c.name as channel_name,
              r.channel_id,
              (SELECT text FROM slack_messages WHERE message_id = r.parent_message_id) as parent_preview
            FROM 
              slack_thread_replies r
              LEFT JOIN slack_channels c ON r.channel_id = c.channel_id
            WHERE 
              r.user_id = ?
            ORDER BY 
              r.timestamp DESC
            LIMIT 20
          `;
          
          db.all(repliesQuery, [userId], (repliesErr, replies) => {
            if (repliesErr) {
              console.error('返信リスト取得エラー:', repliesErr);
              user.replies = [];
            } else {
              // 日付をフォーマット
              replies.forEach(reply => {
                if (reply.date) {
                  reply.date = new Date(parseFloat(reply.date) * 1000);
                }
                // テキストを制限(長すぎる場合)
                if (reply.text_preview && reply.text_preview.length > 200) {
                  reply.text_preview = reply.text_preview.substring(0, 200) + '...';
                }
                if (reply.parent_preview && reply.parent_preview.length > 100) {
                  reply.parent_preview = reply.parent_preview.substring(0, 100) + '...';
                }
              });
              user.replies = replies || [];
            }
            
            // 送信したスタンプを取得
            const sentStampsQuery = `
              SELECT 
                r.name as reaction,
                r.timestamp as date,
                m.text as message_preview,
                r.message_id,
                (SELECT u.display_name || COALESCE(' (' || u.real_name || ')', '') FROM slack_users u JOIN slack_messages msg ON u.user_id = msg.user_id WHERE msg.message_id = r.message_id) as target_user
              FROM 
                slack_reactions r
                LEFT JOIN slack_messages m ON r.message_id = m.message_id
              WHERE 
                r.user_id = ?
              ORDER BY 
                r.timestamp DESC
              LIMIT 20
            `;
            
            db.all(sentStampsQuery, [userId], (sentStampsErr, sentStamps) => {
              if (sentStampsErr) {
                console.error('送信スタンプリスト取得エラー:', sentStampsErr);
                user.sent_stamps = [];
              } else {
                // 日付をフォーマット
                sentStamps.forEach(stamp => {
                  if (stamp.date) {
                    stamp.date = new Date(parseFloat(stamp.date) * 1000);
                  }
                  // テキストを制限(長すぎる場合)
                  if (stamp.message_preview && stamp.message_preview.length > 150) {
                    stamp.message_preview = stamp.message_preview.substring(0, 150) + '...';
                  }
                });
                user.sent_stamps = sentStamps || [];
              }
              
              // 受信したスタンプを取得
              const receivedStampsQuery = `
                SELECT 
                  r.name as reaction,
                  r.timestamp as date,
                  m.text as message_preview,
                  r.message_id,
                  (SELECT u.display_name || COALESCE(' (' || u.real_name || ')', '') FROM slack_users u WHERE u.user_id = r.user_id) as from_user
                FROM 
                  slack_reactions r
                  JOIN slack_messages m ON r.message_id = m.message_id
                WHERE 
                  m.user_id = ?
                ORDER BY 
                  r.timestamp DESC
                LIMIT 20
              `;
              
              db.all(receivedStampsQuery, [userId], (receivedStampsErr, receivedStamps) => {
                if (receivedStampsErr) {
                  console.error('受信スタンプリスト取得エラー:', receivedStampsErr);
                  user.received_stamps = [];
                } else {
                  // 日付をフォーマット
                  receivedStamps.forEach(stamp => {
                    if (stamp.date) {
                      stamp.date = new Date(parseFloat(stamp.date) * 1000);
                    }
                    // テキストを制限(長すぎる場合)
                    if (stamp.message_preview && stamp.message_preview.length > 150) {
                      stamp.message_preview = stamp.message_preview.substring(0, 150) + '...';
                    }
                  });
                  user.received_stamps = receivedStamps || [];
                }
                
                // AI分析結果は削除
                user.analysis = '';
                
                // 成功レスポンス
                res.json(user);
              });
            });
          });
        });
      });
    });
  } catch (error) {
    console.error('ユーザー詳細取得エラー:', error);
    res.status(500).json({ error: 'ユーザー詳細情報の取得に失敗しました' });
  }
});

// 特定ユーザーのメッセージを分析するAPI
app.get('/api/analyze-user/:userId', async (req, res) => {
  try {
    const userId = req.params.userId;
    const isPositive = req.query.positive === 'true';
    console.log(`ユーザー分析リクエスト: ${userId}, ポジティブ分析: ${isPositive}`);
    
    // ユーザー名を取得
    const userQuery = `SELECT name, real_name, display_name FROM slack_users WHERE user_id = ?`;
    
    db.get(userQuery, [userId], async (err, user) => {
      if (err) {
        console.error('ユーザー情報取得エラー:', err);
        return res.status(500).json({ error: 'ユーザー情報の取得に失敗しました' });
      }
      
      if (!user) {
        return res.status(404).json({ error: 'ユーザーが見つかりません' });
      }
      
      const userName = user.display_name || user.real_name || user.name || userId;
      
      // ユーザーのメッセージを取得(最大100件)
      const messageQuery = `
        SELECT text, timestamp FROM slack_messages 
        WHERE user_id = ? 
        ORDER BY timestamp DESC 
        LIMIT 100
      `;
      
      db.all(messageQuery, [userId], async (err, messages) => {
        if (err) {
          console.error('メッセージ取得エラー:', err);
          return res.status(500).json({ error: 'メッセージの取得に失敗しました' });
        }
        
        // メッセージが少なすぎる場合
        if (messages.length < 3) {
          return res.json({
            success: true,
            source: "simple",
            analysis: {
              userName: userName,
              summary: `${userName}さんの分析に必要なメッセージが不足しています。もう少しSlackでの会話データが必要です。`,
              communicationStyle: "データ不足",
              strengths: ["データが不足しています"],
              interests: ["データが不足しています"],
              workStyle: "データが不足しています",
              tags: ["データ不足"]
            }
          });
        }
        
        try {
          // OpenAI APIを使って分析
          const messagesText = messages.map(m => m.text).join('\n\n');
          
          const prompt = currentAiAnalysisPrompt + `

ユーザー名: ${userName}
メッセージ数: ${messages.length}件

【メッセージ一覧】
${messagesText}

分析対象のユーザー名は「${userName}」です。このユーザーの特徴を600文字程度で具体的に分析してください。`;
          
          // OpenAI APIを呼び出し
          const openaiResponse = await fetch('https://api.openai.com/v1/chat/completions', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Authorization': `Bearer ${process.env.OPENAI_API_KEY}`
            },
            body: JSON.stringify({
              model: "gpt-3.5-turbo",
              messages: [
                {
                  role: "system",
                  content: currentAiAnalysisPrompt
                },
                {
                  role: "user",
                  content: prompt
                }
              ],
              temperature: 0.6,
              max_tokens: 500
            })
          });
          
          if (!openaiResponse.ok) {
            console.error('OpenAI APIエラー:', await openaiResponse.text());
            throw new Error('OpenAI APIからの応答に問題がありました');
          }
          
          const openaiData = await openaiResponse.json();
          const analysisText = openaiData.choices[0].message.content.trim();
          
          // クリーンな分析結果を返す
          const sanitizedAnalysis = sanitizeApiResponse(analysisText);
          
          // 分析結果をJSON形式で返す
          return res.json({
            success: true,
            source: "openai",
            analysis: {
              userName: userName,
              summary: sanitizedAnalysis.length > 400 ? sanitizedAnalysis.substring(0, 400) + "..." : sanitizedAnalysis,
              communicationStyle: "分析完了",
              strengths: ["OpenAIによる分析を表示中"],
              interests: ["OpenAIによる分析を表示中"],
              workStyle: "OpenAIによる分析を表示中",
              tags: ["AI分析", "ポジティブ評価"]
            }
          });
          
        } catch (error) {
          console.error('OpenAI API呼び出しエラー:', error);
          
          // エラー時は簡易分析を返す
          return res.json({
            success: true,
            source: "error_fallback",
            error: error.message,
            analysis: {
              userName: userName,
              summary: `${userName}さんの分析中にエラーが発生しました。時間をおいて再度お試しください。エラー: ${error.message}`,
              communicationStyle: "エラー発生",
              strengths: ["データ分析エラー"],
              interests: ["データ分析エラー"],
              workStyle: "データ分析エラー",
              tags: ["エラー"]
            }
          });
        }
      });
    });
    
  } catch (error) {
    console.error('ユーザー分析中にエラーが発生しました:', error);
    res.status(500).json({ error: error.message });
  }
});

// グラフ表示用のTOP30ユーザーを取得するAPI
app.get('/api/stats/top-users', async (req, res) => {
  try {
    const type = req.query.type || req.query.chart || 'messages';
    const period = req.query.period || '3months'; // 新しいパラメータ: 期間(3months, 2weeks, 3days)
    
    let column = 'message_count';
    let title = 'メッセージ投稿数';
    
    // 期間に応じた日付条件を設定 (日本時間ベース)
    let dateCondition = '';
    const now = getJSTDate(); // 日本時間のnowを使用
    
    if (period === '3months') {
      // 過去3ヶ月
      const threeMonthsAgo = new Date(now);
      threeMonthsAgo.setMonth(now.getMonth() - 3);
      dateCondition = `AND timestamp > '${threeMonthsAgo.getTime() / 1000}'`;
      title += '(直近3ヶ月)';
    } else if (period === '2weeks') {
      // 過去1ヶ月(期間名は変更していないが内容を変更)
      const oneMonthAgo = new Date(now);
      oneMonthAgo.setMonth(now.getMonth() - 1);
      dateCondition = `AND timestamp > '${oneMonthAgo.getTime() / 1000}'`;
      title += '(直近1ヶ月)';
    } else if (period === '3days') {
      // 過去1週間(期間名は変更していないが内容を変更)
      const oneWeekAgo = new Date(now);
      oneWeekAgo.setDate(now.getDate() - 7);
      dateCondition = `AND timestamp > '${oneWeekAgo.getTime() / 1000}'`;
      title += '(直近1週間)';
    }
    
    // タイプに応じて取得するカラムを変更
    switch (type) {
      case 'messages':
        column = `(
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id
          ${dateCondition}
        )`;
        title = 'メッセージ投稿数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
        break;
      case 'replies':
        column = `(
          SELECT COUNT(*) FROM slack_thread_replies
          WHERE user_id = u.user_id
          ${dateCondition}
        )`;
        title = 'スレッド返信数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
        break;
      case 'received_reactions':
        column = `(
          SELECT COUNT(*) FROM slack_reactions r
          JOIN slack_messages m ON r.message_id = m.message_id
          WHERE m.user_id = u.user_id
          ${dateCondition.replace('timestamp', 'm.timestamp')}
        )`;
        title = 'スタンプを貰った数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
        break;
      case 'sent_reactions':
        column = `(
          SELECT COUNT(*) FROM slack_reactions r
          WHERE r.user_id = u.user_id
          ${dateCondition.replace('timestamp', 'r.timestamp')}
        )`;
        title = 'スタンプを送った数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
        break;
      case 'attendance':
        column = `(
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND (
            text LIKE '/yolo%' OR
            text LIKE '/Yolo%'
          )
          ${dateCondition}
        )`;
        title = '出勤数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
        break;
      case 'office':
        column = `(
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND (
            text LIKE '/yolo_office%' OR 
            text LIKE '/Yolo_office%'
          )
          ${dateCondition}
        )`;
        title = 'リアル出社数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
        break;
      case 'reports':
        column = `(
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND length(text) >= 50
          ${dateCondition}
        )`;
        title = '発信数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
        break;
      default:
        column = `(SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id ${dateCondition})`;
        title = 'メッセージ投稿数' + (title.includes('(') ? title.substring(title.indexOf('(')) : '');
    }
    
    // SQL実行
    const query = `
      SELECT 
        u.user_id, 
        u.name, 
        u.real_name, 
        u.display_name,
        u.avatar,
        ${column}  count_value
      FROM 
        slack_users u
      WHERE 
        u.is_bot = 0
        AND ${column} > 0
      ORDER BY 
        count_value DESC
      LIMIT 30
    `;
    
    db.all(query, [], (err, users) => {
      if (err) {
        console.error('グラフデータ取得エラー:', err);
        return res.status(500).json({ error: 'グラフデータの取得に失敗しました', success: false });
      }
      
      // 表示名を設定
      users.forEach(user => {
        user.displayName = user.display_name || user.real_name || user.name || 'Unknown';
      });
      
      res.json({
        success: true,
        title: title,
        data: users,
        users: users  // clientが users フィールドを期待している場合に対応
      });
    });
  } catch (error) {
    console.error('グラフデータ取得エラー:', error);
    res.status(500).json({ error: 'グラフデータの取得に失敗しました', success: false });
  }
});

// サーバー起動
app.listen(port, () => {
  console.log(`サーバーが起動しました: http://localhost:${port}`);
  console.log(`テストページ: http://localhost:${port}/simple`);
  
  // サーバー起動時にも自動同期チェックを実行
  setTimeout(async () => {
    console.log('サーバー起動後の自動同期チェックを実行します...');
    try {
      await checkAndSyncIfNeeded();
      console.log('サーバー起動後の自動同期チェック完了');
    } catch (error) {
      console.error('サーバー起動後の自動同期チェックでエラーが発生:', error);
    }
  }, 3000); // サーバー起動から3秒後に実行
});

// データベースを全削除するAPI
app.post('/api/reset-database', (req, res) => {
  try {
    console.log('データベースの全削除を開始します...');
    
    // トランザクションを開始
    db.serialize(() => {
      // 各テーブルを空にする
      db.run('DELETE FROM slack_messages', function(err) {
        if (err) {
          console.error('slack_messagesテーブル削除エラー:', err);
        } else {
          console.log(`slack_messagesから${this.changes}件のデータを削除しました`);
        }
      });
      
      db.run('DELETE FROM slack_thread_replies', function(err) {
        if (err) {
          console.error('slack_thread_repliesテーブル削除エラー:', err);
        } else {
          console.log(`slack_thread_repliesから${this.changes}件のデータを削除しました`);
        }
      });
      
      db.run('DELETE FROM slack_reactions', function(err) {
        if (err) {
          console.error('slack_reactionsテーブル削除エラー:', err);
        } else {
          console.log(`slack_reactionsから${this.changes}件のデータを削除しました`);
        }
      });
      
      db.run('DELETE FROM slack_users', function(err) {
        if (err) {
          console.error('slack_usersテーブル削除エラー:', err);
        } else {
          console.log(`slack_usersから${this.changes}件のデータを削除しました`);
        }
      });
      
      db.run('DELETE FROM slack_channels', function(err) {
        if (err) {
          console.error('slack_channelsテーブル削除エラー:', err);
        } else {
          console.log(`slack_channelsから${this.changes}件のデータを削除しました`);
        }
      });
      
      db.run('DELETE FROM user_analysis', function(err) {
        if (err) {
          console.error('user_analysisテーブル削除エラー:', err);
        } else {
          console.log(`user_analysisから${this.changes}件のデータを削除しました`);
        }
      });
    });
    
    console.log('データベースの全削除が完了しました');
    res.json({ success: true, message: 'データベースを全削除しました' });
  } catch (error) {
    console.error('データベース削除エラー:', error);
    res.status(500).json({ error: 'データベースの削除に失敗しました' });
  }
});

// 最終同期日時を特定の日付に設定するAPI(テスト用)
app.post('/api/set-last-sync-date', express.json(), (req, res) => {
  try {
    // リクエストから日時情報を取得
    const { date } = req.body;
    
    if (!date) {
      // デフォルトで2024年5月9日14:00に設定
      const defaultDate = new Date('2024-05-09T14:00:00+09:00');
      const defaultTimestamp = Math.floor(defaultDate.getTime() / 1000);
      
      // データベース更新
      db.run('DELETE FROM last_sync_date', function(deleteErr) {
        if (deleteErr) {
          console.error('最終同期日時の削除エラー:', deleteErr);
          return res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
        }
        
        const dateString = defaultDate.toISOString().split('T')[0];
        db.run(
          'INSERT INTO last_sync_date (last_sync_date, last_sync_timestamp) VALUES (?, ?)',
          [dateString, defaultTimestamp],
          function(insertErr) {
            if (insertErr) {
              console.error('最終同期日時の挿入エラー:', insertErr);
              return res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
            }
            
            console.log(`最終同期日時を設定しました: ${dateString} (${defaultTimestamp})`);
            return res.json({
              success: true,
              message: `最終同期日時を ${dateString} に設定しました`,
              date: dateString,
              timestamp: defaultTimestamp
            });
          }
        );
      });
    } else {
      // 指定された日時を使用
      const specifiedDate = new Date(date);
      const timestamp = Math.floor(specifiedDate.getTime() / 1000);
      
      // データベース更新
      db.run('DELETE FROM last_sync_date', function(deleteErr) {
        if (deleteErr) {
          console.error('最終同期日時の削除エラー:', deleteErr);
          return res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
        }
        
        const dateString = specifiedDate.toISOString().split('T')[0];
        db.run(
          'INSERT INTO last_sync_date (last_sync_date, last_sync_timestamp) VALUES (?, ?)',
          [dateString, timestamp],
          function(insertErr) {
            if (insertErr) {
              console.error('最終同期日時の挿入エラー:', insertErr);
              return res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
            }
            
            console.log(`最終同期日時を設定しました: ${dateString} (${timestamp})`);
            return res.json({
              success: true,
              message: `最終同期日時を ${dateString} に設定しました`,
              date: dateString,
              timestamp: timestamp
            });
          }
        );
      });
    }
  } catch (error) {
    console.error('最終同期日時設定エラー:', error);
    res.status(500).json({ error: '最終同期日時の設定に失敗しました' });
  }
});

// グローバル関数として追加:JSON解析エラー診断と修正
function fixJsonParseError(jsonString, errorMessage) {
  console.log('JSON解析エラー修正を試みます...');
  
  // jsonStringがundefinedやnullでないことを確認
  if (!jsonString) {
    console.error('修正対象のjsonStringが未定義です');
    return '';
  }
  
  console.log(`元のエラー: ${errorMessage}`);
  console.log(`JSON文字列長: ${jsonString.length}文字`);
  
  // エラーメッセージの詳細をさらに表示
  if (errorMessage.includes('Unexpected non-whitespace character after JSON at position 67')) {
    console.log('===== 位置67エラーの詳細診断 =====');
    console.log(`エラー位置周辺(55-80)の文字列: "${jsonString.substring(55, 80)}"`);
    
    // 各文字のコードポイントを表示
    console.log('文字ごとのコードポイント:');
    for (let i = 60; i < 75; i++) {
      if (i < jsonString.length) {
        const char = jsonString.charAt(i);
        console.log(`位置${i}: "${char}" (コード: ${char.charCodeAt(0)}, 16進数: 0x${char.charCodeAt(0).toString(16)})`);
      }
    }
    
    // 16進数でダンプ
    console.log('16進数ダンプ (位置60-75):');
    let hexDump = '';
    for (let i = 60; i < 75; i++) {
      if (i < jsonString.length) {
        hexDump += jsonString.charCodeAt(i).toString(16).padStart(2, '0') + ' ';
      }
    }
    console.log(hexDump);
  }
  
  // 特定のエラーメッセージに対する特殊処理
  if (errorMessage.includes('Unexpected non-whitespace character after JSON at position 67') ||
      (errorMessage.includes('Unexpected token') && errorMessage.includes('position 67'))) {
    console.log('特定の位置67エラーを検出しました - 専用の修正関数を呼び出します');
    const fixedString = fixPosition67Error(jsonString);
    
    // 修正前後の比較ログ
    if (fixedString !== jsonString) {
      console.log('修正が適用されました:');
      console.log(`修正前(位置60-75): "${jsonString.substring(60, 75)}"`);
      console.log(`修正後(位置60-75): "${fixedString.substring(60, 75)}"`);
      
      // 修正が正常にパースできるか確認
      try {
        JSON.parse(fixedString);
        console.log('修正後のJSONは正常にパースできました!');
      } catch (e) {
        console.log(`修正後もパースできません: ${e.message}`);
      }
    } else {
      console.log('専用関数による修正は適用されませんでした');
    }
    
    return fixedString;
  }
  
  // 共通の問題:位置を特定
  const positionMatch = errorMessage.match(/position\s+(\d+)/i);
  if (!positionMatch) {
    console.log('エラー位置を特定できませんでした');
    return jsonString; // 修正できない場合は元の文字列を返す
  }
  
  const errorPos = parseInt(positionMatch[1]);
  console.log(`エラー位置: ${errorPos}`);
  
  // エラー周辺のコンテキストを表示
  const contextStart = Math.max(0, errorPos - 15);
  const contextEnd = Math.min(jsonString.length, errorPos + 15);
  console.log(`エラー周辺: "${jsonString.substring(contextStart, contextEnd)}"`);
  
  // エラー位置の文字とその前後
  const errorChar = jsonString.charAt(errorPos);
  const beforeChar = errorPos > 0 ? jsonString.charAt(errorPos - 1) : '';
  const afterChar = errorPos < jsonString.length - 1 ? jsonString.charAt(errorPos + 1) : '';
  
  console.log(`問題の文字: "${beforeChar}[${errorChar}]${afterChar}" (コード: ${errorChar.charCodeAt(0)})`);
  
  // 特定のケース:位置67問題(非常に一般的なエラー)
  if (errorPos >= 66 && errorPos <= 68) {
    console.log('位置67周辺の一般的な問題を検出');
    
    // 位置67の文字を削除してみる(最も効果的な修正方法)
    let fixedJson = jsonString.substring(0, errorPos) + jsonString.substring(errorPos + 1);
    try {
      JSON.parse(fixedJson);
      console.log('位置67の文字を削除することで修正に成功しました');
      return fixedJson;
    } catch (e) {
      console.log('位置67の文字削除では修正できませんでした');
    }
  }
  
  // ケース1:無効な文字(制御文字など)
  if (!/[\x20-\x7E]/.test(errorChar)) {
    console.log('無効な文字を検出しました');
    const fixedJson = jsonString.substring(0, errorPos) + jsonString.substring(errorPos + 1);
    try {
      JSON.parse(fixedJson);
      console.log('無効な文字を削除することで修正に成功しました');
      return fixedJson;
    } catch (e) {
      console.log('無効な文字の削除では修正できませんでした');
    }
  }
  
  // ケース2:余分なカンマ
  if (errorChar === ',' && (afterChar === '}' || afterChar === ']' || afterChar === ',')) {
    console.log('余分なカンマを検出しました');
    const fixedJson = jsonString.substring(0, errorPos) + jsonString.substring(errorPos + 1);
    try {
      JSON.parse(fixedJson);
      console.log('余分なカンマを削除することで修正に成功しました');
      return fixedJson;
    } catch (e) {
      console.log('余分なカンマの削除では修正できませんでした');
    }
  }
  
  // 最後の手段:エラー位置の文字を単純に削除
  if (errorPos < jsonString.length) {
    const fixedJson = jsonString.substring(0, errorPos) + jsonString.substring(errorPos + 1);
    try {
      JSON.parse(fixedJson);
      console.log('エラー位置の文字を削除することで修正に成功しました');
      return fixedJson;
    } catch (e) {
      console.log('エラー位置の文字削除では修正できませんでした');
    }
  }
  
  // どの方法でも修正できなかった場合
  console.log('自動修正は失敗しました。元のJSONを返します。');
  return jsonString;
}

// 67文字目付近での特定のJSON解析エラーを修正する特殊関数
function fixPosition67Error(jsonString) {
  console.log('特定の67文字目エラー修正を試みます...');
  
  // jsonStringがundefinedやnullでないことを確認
  if (!jsonString) {
    console.error('修正対象のjsonStringが未定義です');
    return '';
  }
  
  // より詳細なデバッグ情報を追加
  if (jsonString.length < 67) {
    console.log('文字列が短すぎるため、該当エラーではありません(長さ: ' + jsonString.length + ')');
    return jsonString;
  }
  
  // 位置67前後の文字を詳細にログ出力
  console.log(`位置67周辺(60-75): "${jsonString.substring(60, 75)}"`);
  
  // バイナリダンプ形式で位置67付近の文字コードを出力
  let binaryDump = '位置67周辺のバイナリダンプ:\n';
  for (let i = 65; i <= 70; i++) {
    if (i < jsonString.length) {
      const char = jsonString.charAt(i);
      const code = jsonString.charCodeAt(i);
      binaryDump += `位置${i}: "${char}" (ASCII: ${code}, HEX: 0x${code.toString(16)})\n`;
    }
  }
  console.log(binaryDump);
  
  // 位置67の文字の詳細診断
  if (jsonString.length > 67) {
    const char67 = jsonString.charAt(67);
    console.log(`位置67の文字: "${char67}" (コード: ${char67.charCodeAt(0)}, 16進数: 0x${char67.charCodeAt(0).toString(16)})`);
    
    // 不可視文字の詳細診断
    if (!/[\x20-\x7E\u3000-\u9FFF]/.test(char67)) {
      console.log('位置67に不可視文字または非ASCII文字を検出しました');
      if (char67 === '\u200B') console.log('→ ゼロ幅スペース (U+200B)');
      else if (char67 === '\u200C') console.log('→ ゼロ幅非接合子 (U+200C)');
      else if (char67 === '\u200D') console.log('→ ゼロ幅接合子 (U+200D)');
      else if (char67 === '\uFEFF') console.log('→ BOM (U+FEFF)');
      else console.log('→ その他の不可視/非ASCII文字');
    }
    
    // 前後の文脈も確認して、JSON構造上の問題を診断
    const before = jsonString.substring(Math.max(0, 67 - 10), 67);
    const after = jsonString.substring(68, Math.min(jsonString.length, 68 + 10));
    console.log(`位置67の前10文字: "${before}"`);
    console.log(`位置67の後10文字: "${after}"`);
    
    // JSON文字列内かオブジェクトの境界かを判定
    let isInString = false;
    let quotePos = -1;
    for (let i = 0; i < 67; i++) {
      if (jsonString[i] === '"' && (i === 0 || jsonString[i-1] !== '\\')) {
        isInString = !isInString;
        quotePos = i;
      }
    }
    
    if (isInString) {
      console.log(`位置67はJSON文字列内にあります(最後の引用符位置: ${quotePos})`);
    } else {
      console.log('位置67はJSON文字列の外にあります');
    }
  }
  
  // 修正戦略を複数適用し、成功したらすぐに返す
  
  // 修正戦略1: 位置67周辺の文字を直接削除
  console.log('修正戦略1: 位置67の文字を直接削除');
  if (jsonString.length > 67) {
    const forcedRemoval = jsonString.substring(0, 67) + jsonString.substring(68);
    try {
      JSON.parse(forcedRemoval);
      console.log('修正戦略1が成功しました!');
      return forcedRemoval;
    } catch (e) {
      console.log(`修正戦略1が失敗: ${e.message}`);
    }
  }
  
  // 修正戦略2: 位置67の前後の文字も含めて削除(5文字ほど)
  console.log('修正戦略2: 位置67周辺(65-70)の文字を削除');
  if (jsonString.length > 70) {
    const widerRemoval = jsonString.substring(0, 65) + jsonString.substring(70);
    try {
      JSON.parse(widerRemoval);
      console.log('修正戦略2が成功しました!');
      return widerRemoval;
    } catch (e) {
      console.log(`修正戦略2が失敗: ${e.message}`);
    }
  }
  
  // 修正戦略3: もし引用符の問題なら、文字列全体を再構成
  console.log('修正戦略3: JSONの文字列構造を修正');
  try {
    // 文字列内の引用符を確認
    let processedJson = '';
    let inString = false;
    let escapeNext = false;
    let lastQuotePos = -1;
    
    for (let i = 0; i < jsonString.length; i++) {
      const char = jsonString.charAt(i);
      
      if (escapeNext) {
        processedJson += char;
        escapeNext = false;
        continue;
      }
      
      if (char === '\\') {
        processedJson += char;
        escapeNext = true;
        continue;
      }
      
      if (char === '"') {
        if (!inString) {
          lastQuotePos = i;
          inString = true;
        } else {
          // 文字列終了
          inString = false;
        }
      }
      
      // 位置67付近の問題を避ける特殊処理
      if (i === 67) {
        if (inString && lastQuotePos === 66) {
          // もし位置67が文字列の最初の文字なら、削除しない
          processedJson += char;
        } else if (inString) {
          // 文字列内の場合は特に注意して処理
          if (!/[\x20-\x7E\u3000-\u9FFF]/.test(char) || char === '\\') {
            console.log(`位置${i}の文字「${char}」を削除します(文字列内)`);
            continue; // この文字をスキップ
          } else {
            processedJson += char;
          }
        } else {
          // 文字列外の場合、位置67の文字は削除を試みる
          console.log(`位置${i}の文字「${char}」を削除します(文字列外)`);
          continue; // この文字をスキップ
        }
      } else {
        processedJson += char;
      }
    }
    
    try {
      JSON.parse(processedJson);
      console.log('修正戦略3が成功しました!');
      return processedJson;
    } catch (e) {
      console.log(`修正戦略3が失敗: ${e.message}`);
    }
  } catch (e) {
    console.log(`修正戦略3の実行中にエラー: ${e.message}`);
  }
  
  // 修正戦略4: JSON本体の抽出({から始まる最初の完全なJSONオブジェクト)
  console.log('修正戦略4: 完全なJSONオブジェクトを抽出');
  try {
    // JSONのフォーマットを厳密にチェック
    let depth = 0;
    let inJSONString = false;
    let escapeChar = false;
    let validStart = -1;
    let validEnd = -1;
    
    for (let i = 0; i < jsonString.length; i++) {
      const char = jsonString.charAt(i);
      
      if (escapeChar) {
        escapeChar = false;
        continue;
      }
      
      if (inJSONString && char === '\\') {
        escapeChar = true;
        continue;
      }
      
      if (char === '"' && (i === 0 || jsonString.charAt(i-1) !== '\\')) {
        inJSONString = !inJSONString;
        continue;
      }
      
      if (inJSONString) {
        continue; // 文字列内の文字は無視
      }
      
      if (char === '{') {
        if (depth === 0) {
          validStart = i;
        }
        depth++;
      } else if (char === '}') {
        depth--;
        if (depth === 0 && validStart >= 0) {
          validEnd = i;
          break; // 完全なJSONオブジェクトを見つけた
        }
      }
    }
    
    if (validStart >= 0 && validEnd > validStart) {
      const extractedJson = jsonString.substring(validStart, validEnd + 1);
      try {
        JSON.parse(extractedJson);
        console.log('修正戦略4が成功しました!');
        return extractedJson;
      } catch (e) {
        console.log(`修正戦略4が失敗: ${e.message}`);
      }
    } else {
      console.log('有効なJSONオブジェクトを見つけられませんでした');
    }
  } catch (e) {
    console.log(`修正戦略4の実行中にエラー: ${e.message}`);
  }
  
  // 修正戦略5: すべての制御文字や問題文字を一括削除
  console.log('修正戦略5: 非表示文字および制御文字を一括削除');
  try {
    let sanitizedJson = jsonString
      .replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF\u2028\u2029]/g, '')
      .replace(/\\\\/g, '\\')  // 二重バックスラッシュの修正
      .replace(/,\s*}/g, '}')  // 末尾のカンマの修正
      .replace(/,\s*]/g, ']')  // 配列末尾のカンマの修正
      .replace(/\\(?!["\\/bfnrt])/g, ''); // 無効なエスケープシーケンスの修正
    
    // 特に位置67付近を重点的に確認
    if (sanitizedJson.length >= 67) {
      const char67 = sanitizedJson.charAt(67);
      if (!/[\x20-\x7E\u3000-\u9FFF]/.test(char67) || char67 === '\\') {
        sanitizedJson = sanitizedJson.substring(0, 67) + sanitizedJson.substring(68);
      }
    }
    
    try {
      JSON.parse(sanitizedJson);
      console.log('修正戦略5が成功しました!');
      return sanitizedJson;
    } catch (e) {
      console.log(`修正戦略5が失敗: ${e.message}`);
    }
  } catch (e) {
    console.log(`修正戦略5の実行中にエラー: ${e.message}`);
  }
  
  // 修正戦略6: 完全なクリーニングを実行
  try {
    console.log('修正戦略6: 完全なクリーニングを実行');
    const fullyCleaned = cleanupAllProblematicCharacters(jsonString);
    if (fullyCleaned !== jsonString) {
      try {
        JSON.parse(fullyCleaned);
        console.log('修正戦略6(完全クリーニング)が成功しました!');
        return fullyCleaned;
      } catch (e) {
        console.log(`修正戦略6が失敗: ${e.message}`);
      }
    } else {
      console.log('完全クリーニングでの変更はありませんでした');
    }
  } catch (e) {
    console.log(`修正戦略6の実行中にエラー: ${e.message}`);
  }
  
  console.log('すべての修正戦略が失敗しました。元の文字列を返します。');
  return jsonString; // 修正できなかった場合は元の文字列を返す
}

// 新しい修正関数を追加: すべての不可視文字と問題のある文字を削除
function cleanupAllProblematicCharacters(jsonString) {
  console.log('すべての不可視文字と問題のある文字をクリーニングします...');
  
  // jsonStringがundefinedやnullでないことを確認
  if (!jsonString) {
    console.error('クリーニング対象のjsonStringが未定義です');
    return '';
  }
  
  // オリジナルの長さを記録
  const originalLength = jsonString.length;
  
  // 前後の余分なスペースを削除
  let cleaned = jsonString.trim();
  
  // 位置67付近の詳細診断
  if (cleaned.length >= 67) {
    const char67 = cleaned.charAt(67);
    console.log(`クリーニング前の位置67の文字: "${char67}" (コード: ${char67.charCodeAt(0)}, 16進数: 0x${char67.charCodeAt(0).toString(16)})`);
    
    // 位置67の文字をバイナリで表示
    let binaryDump = '位置67の文字をバイナリ表現: ';
    try {
      const code = cleaned.charCodeAt(67);
      let binary = code.toString(2);
      while (binary.length < 8) binary = '0' + binary;
      binaryDump += binary;
      console.log(binaryDump);
    } catch (e) {
      console.error('バイナリダンプ失敗:', e.message);
    }
  }
  
  // マルチステップクリーニング
  try {
    // ステップ1: マークダウンコードブロック(```json```)を削除
    const jsonBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)\s*```/);
    if (jsonBlockMatch && jsonBlockMatch[1]) {
      cleaned = jsonBlockMatch[1].trim();
      console.log('マークダウンコードブロックを除去しました');
    }
    
    // ステップ2: JSON本体の抽出({から始まり}で終わる部分を探す)
    const jsonObjectMatch = cleaned.match(/{[\s\S]*}/);
    if (jsonObjectMatch) {
      cleaned = jsonObjectMatch[0];
      console.log('JSONオブジェクト本体を抽出しました');
    }
    
    // ステップ3: すべての制御文字とUnicode特殊文字を削除
    cleaned = cleaned
      .replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF\u2028\u2029]/g, '') // 制御文字とゼロ幅文字
      .replace(/\uFEFF/g, '') // BOMを明示的に削除
      .replace(/,\s*}/g, '}') // 末尾のカンマ
      .replace(/,\s*]/g, ']') // 配列末尾のカンマ
      .replace(/"\s*:\s*"/g, '":"') // 引用符の間の余分なスペース
      .replace(/\\(?!["\\/bfnrt])/g, ''); // 無効なエスケープシーケンス
    
    // ステップ3.5: 日本語などの全角文字は保持しつつ、不正な非ASCII文字のみを削除
    cleaned = cleaned.replace(/[\x00-\x1F\x7F-\x9F]/g, '');
    
    // ステップ4: 位置67周辺の文字を優先的に修正(最も一般的な問題箇所)
    if (cleaned.length >= 68) {
      // 位置67の前後3文字を確認
      for (let i = Math.max(0, 67 - 3); i <= Math.min(cleaned.length - 1, 67 + 3); i++) {
        const charToCheck = cleaned.charAt(i);
        // 不審な文字や制御文字があれば削除
        if (!/[\x20-\x7E\u3000-\u9FFF]/.test(charToCheck) || charToCheck === '\\' || 
            (charToCheck === '"' && (cleaned.charAt(i-1) !== '\\' && cleaned.charAt(i+1) !== ':' && cleaned.charAt(i-1) !== ':'))) {
          console.log(`位置${i}の問題文字「${charToCheck}」を削除します`);
          cleaned = cleaned.substring(0, i) + cleaned.substring(i + 1);
          i--; // インデックスを調整
        }
      }
    }
    
    // ステップ5: 特に位置67を強制的に確認(最も多い問題箇所)
    if (cleaned.length >= 67) {
      const char67 = cleaned.charAt(67);
      // 位置67が特に問題を起こしやすいため特別チェック
      if (!/[\x20-\x7E\u3000-\u9FFF]/.test(char67) || char67 === '\\' || 
          (char67 === '"' && (cleaned.charAt(66) !== '\\' && cleaned.charAt(68) !== ':' && cleaned.charAt(66) !== ':'))) {
        console.log(`位置67の問題文字「${char67}」を強制削除します`);
        cleaned = cleaned.substring(0, 67) + cleaned.substring(68);
      }
    }
  } catch (e) {
    console.error('クリーニング処理中にエラー:', e.message);
  }
  
  // 削除された文字数を報告
  const removedChars = originalLength - cleaned.length;
  console.log(`クリーニングにより ${removedChars} 文字を削除しました`);
  
  // JSONが解析可能か確認(成功すれば報告のみ)
  try {
    JSON.parse(cleaned);
    console.log('クリーニング後のJSONは正常にパースできました!');
  } catch (e) {
    console.log(`クリーニング後もパースできません: ${e.message}`);
    console.log('クリーニング後の文字列(最初の100文字):', cleaned.substring(0, 100));
    
    // 最後の手段として一般的なJSONの問題を修正
    try {
      // 引用符が閉じられていない問題を修正
      const quoteCount = (cleaned.match(/"/g) || []).length;
      if (quoteCount % 2 !== 0) {
        console.log('引用符の数が奇数です。修正を試みます');
        
        // 未閉じの引用符を特定して閉じる
        let inString = false;
        let escapedChar = false;
        let fixedJson = '';
        
        for (let i = 0; i < cleaned.length; i++) {
          const char = cleaned.charAt(i);
          
          if (char === '\\' && !escapedChar) {
            escapedChar = true;
            fixedJson += char;
            continue;
          }
          
          if (char === '"' && !escapedChar) {
            inString = !inString;
          }
          
          escapedChar = false;
          fixedJson += char;
        }
        
        // 文字列が閉じられていなければ閉じる
        if (inString) {
          fixedJson += '"';
          console.log('未閉じの引用符を閉じました');
        }
        
        cleaned = fixedJson;
      }
      
      // オブジェクトが閉じられていない問題を修正
      const openBraces = (cleaned.match(/{/g) || []).length;
      const closeBraces = (cleaned.match(/}/g) || []).length;
      
      if (openBraces > closeBraces) {
        // 閉じられていない中括弧を追加
        const diff = openBraces - closeBraces;
        cleaned = cleaned + '}'.repeat(diff);
        console.log(`${diff}個の閉じ中括弧を追加しました`);
      }
      
      // 配列が閉じられていない問題を修正
      const openBrackets = (cleaned.match(/\[/g) || []).length;
      const closeBrackets = (cleaned.match(/\]/g) || []).length;
      
      if (openBrackets > closeBrackets) {
        // 閉じられていない角括弧を追加
        const diff = openBrackets - closeBrackets;
        cleaned = cleaned + ']'.repeat(diff);
        console.log(`${diff}個の閉じ角括弧を追加しました`);
      }
      
      // 最終チェック - パース可能か確認
      try {
        JSON.parse(cleaned);
        console.log('構造の修正により、JSONは正常にパースできるようになりました!');
      } catch (finalError) {
        console.log('すべての修正を適用しても解析エラーが残っています:', finalError.message);
      }
    } catch (fixError) {
      console.error('構造修正中にエラー:', fixError.message);
    }
  }
  
  return cleaned;
}

// ユーザー分析用の簡易分析関数を追加
function createSimpleUserAnalysis(messages, userName) {
  console.log('簡易ユーザー分析を実行します');
  
  // すべてのテキストを結合
  const allText = messages.map(msg => msg.text || '').join(' ');
  
  // 簡易的なキーワード抽出
  const words = allText.toLowerCase().split(/\s+/);
  const wordCounts = {};
  const ignoreWords = ['て', 'に', 'は', 'を', 'の', 'が', 'と', 'です', 'ます', 'した', 'から', 'ない'];
  
  words.forEach(word => {
    if (word.length > 1 && !ignoreWords.includes(word)) {
      wordCounts[word] = (wordCounts[word] || 0) + 1;
    }
  });
  
  const keywords = Object.entries(wordCounts)
    .sort((a, b) => b[1] - a[1])
    .slice(0, 10)
    .map(entry => entry[0]);
  
  // 文章の長さを分析
  const avgLength = Math.round(allText.length / Math.max(1, messages.length));
  
  return {
    userName: userName,
    summary: `${userName}さんは、長文メッセージを投稿する傾向があります。平均文字数は約${avgLength}文字です。`,
    communicationStyle: "詳細な情報を提供する傾向があります。",
    strengths: ["詳細な情報提供", "丁寧な説明", "文章での表現力"],
    interests: keywords.slice(0, 3).map(kw => `「${kw}」に関連するトピック`),
    workStyle: "文章での情報共有を重視しているようです。",
    projectAreas: keywords.slice(3, 5),
    technicalExpertise: keywords.slice(5, 7),
    tips: ["詳細な情報を提供することを好む傾向があります", "文章でのコミュニケーションが得意なようです", "簡潔な返信よりも詳細な説明が好みかもしれません"],
    bestPractices: ["長文の返信に対応する", "詳細な情報を共有する", "文脈を意識したコミュニケーション"],
    tags: keywords.slice(0, 5)
  };
}

// APIレスポンス用のJSON安全化関数
function sanitizeApiResponse(data) {
  try {
    // すでにJSON文字列の場合は安全なJSONとして処理
    if (typeof data === 'string') {
      try {
        // いったんパースして検証
        const parsed = JSON.parse(data);
        // 成功したら文字列に戻す
        return JSON.stringify(parsed);
      } catch (parseError) {
        console.error('応答文字列のJSON解析エラー:', parseError.message);
        // 問題のある場合はクリーニング処理を実行
        return JSON.stringify(cleanupAllProblematicCharacters(data));
      }
    }
    
    // オブジェクトの場合は安全な形式に変換
    return JSON.stringify(data, (key, value) => {
      // 特殊文字や不正な文字を含む可能性のある文字列を処理
      if (typeof value === 'string') {
        // 制御文字を削除
        return value.replace(/[\u0000-\u001F\u007F-\u009F\u200B-\u200D\uFEFF]/g, '');
      }
      return value;
    });
  } catch (error) {
    console.error('APIレスポンス安全化エラー:', error);
    // エラーが発生した場合でも何らかの応答を返す
    return JSON.stringify({
      error: 'レスポンス処理中にエラーが発生しました',
      details: error.message
    });
  }
}

// ピックアップユーザーを取得するAPI(発信数5件以上のユーザーからランダムに1人選ぶ)
app.get('/api/pickup-user', async (req, res) => {
  try {
    // 今日の日付を取得(この値はログ表示やデバッグ用)
    const today = getJSTDateString();
    console.log(`今日の日付: ${today}`);
    
    // 今日の日付でピックアップユーザーを検索
    const targetDate = today;
    console.log(`検索対象日: ${targetDate}`);
    
    // 指定した日付のピックアップユーザーをランダムに1人取得
    db.get('SELECT * FROM daily_pickup_user WHERE pickup_date = ? ORDER BY RANDOM() LIMIT 1', [targetDate], async (err, pickupUser) => {
      if (err) {
        console.error('イチ推しbraver取得エラー:', err);
        return res.status(500).json({ error: 'イチ推しbraverの取得に失敗しました' });
      }
      
      // イチ推しbraverが存在する場合はそれを返す
      if (pickupUser) {
        console.log(`検索日(${targetDate})のイチ推しbraverを返します: ${pickupUser.display_name || pickupUser.real_name || pickupUser.name}`);
        console.log(`【活動データ】投稿数: ${pickupUser.message_count}, 返信数: ${pickupUser.reply_count}, 出勤数: ${pickupUser.attendance_count}, リアル出社: ${pickupUser.office_count}, 週報数: ${pickupUser.report_count}, 送信スタンプ: ${pickupUser.sent_reaction_count}, 受信スタンプ: ${pickupUser.received_reaction_count}`);
        
        // 表示名を設定
        pickupUser.displayName = pickupUser.display_name || pickupUser.real_name || pickupUser.name || 'Unknown';
        
        // 結果を返す
        return res.json({
          success: true,
          user: pickupUser
        });
      }
      
      // 指定日のイチ推しbraverが見つからない場合は、同期処理がまだ実行されていない可能性がある
      // 安全のため、古い選出ロジックをバックアップとして実行(前回選択されたユーザーは除外)
      console.log(`${targetDate}のイチ推しbraverが見つかりません。バックアップ選出処理を実行します。`);
      
      // 前回選択されたユーザーIDを取得(クエリパラメータから)
      const previousUserId = req.query.previousUserId || '';
      
      // 発信数5件以上のユーザーをクエリ(シンプル化したクエリ)
      const pickupQuery = `
        SELECT 
          u.user_id, 
          u.name, 
          u.real_name, 
          u.display_name, 
          REPLACE(u.avatar, '_72', '_512') AS avatar,
          (SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id) AS message_count,
          (SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = u.user_id) AS reply_count,
          (
            SELECT COUNT(*) FROM slack_messages 
            WHERE user_id = u.user_id 
            AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')
          ) AS attendance_count,
          (
            SELECT COUNT(*) FROM slack_messages 
            WHERE user_id = u.user_id 
            AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')
          ) AS office_count,
          (
            SELECT COUNT(*) FROM slack_messages
            WHERE user_id = u.user_id
            AND length(text) >= 50
          ) AS report_count,
          0 AS sent_reaction_count,
          0 AS received_reaction_count
        FROM 
          slack_users u
        WHERE 
          u.is_bot = 0
          AND (
            SELECT COUNT(*) FROM slack_messages 
            WHERE user_id = u.user_id
          ) >= 5
      `;
      
      // クエリを実行
      db.all(pickupQuery, [], async (err, users) => {
        if (err) {
          console.error('ピックアップユーザー取得エラー:', err);
          return res.status(500).json({ error: 'ピックアップユーザーの取得に失敗しました' });
        }
        
        // 条件に合うユーザーがいない場合
        if (users.length === 0) {
          return res.json({ 
            success: false, 
            error: '発信数5件以上のユーザーが見つかりませんでした' 
          });
        }
        
        // ランダムに1人選ぶ
        const randomIndex = Math.floor(Math.random() * users.length);
        const selectedUser = users[randomIndex];
        
        // 表示名を設定
        selectedUser.displayName = selectedUser.display_name || selectedUser.real_name || selectedUser.name || 'Unknown';
        
        // 高画質アバターURLを確保(バックアップ)
        if (selectedUser.avatar && selectedUser.avatar.includes('_72')) {
          selectedUser.avatar_hd = selectedUser.avatar.replace('_72', '_512');
        } else {
          selectedUser.avatar_hd = selectedUser.avatar;
        }
        
        // 活動データから基本情報を作成
        const messageCount = selectedUser.message_count || 0;
        const replyCount = selectedUser.reply_count || 0;
        const reportCount = selectedUser.report_count || 0;
        const attendanceCount = selectedUser.attendance_count || 0;
        const officeCount = selectedUser.office_count || 0;
        
        const activityInfo = `${selectedUser.displayName}さんは、投稿数${messageCount}件、発信数${reportCount}件と活発に活動されています。出勤数は${attendanceCount}日、リアル出社は${officeCount}日です。チーム内での積極的なコミュニケーションが特徴的です!`;
        
        // 簡易的な分析結果を作成
        selectedUser.analysis = activityInfo;
        
        // 結果を返す
        return res.json({
          success: true,
          user: {
            ...selectedUser,
            // 数値型を確保
            message_count: parseInt(selectedUser.message_count || 0),
            reply_count: parseInt(selectedUser.reply_count || 0),
            attendance_count: parseInt(selectedUser.attendance_count || 0),
            office_count: parseInt(selectedUser.office_count || 0),
            report_count: parseInt(selectedUser.report_count || 0),
            sent_reaction_count: parseInt(selectedUser.sent_reaction_count || 0),
            received_reaction_count: parseInt(selectedUser.received_reaction_count || 0)
          }
        });
      });
    });
  } catch (error) {
    console.error('リアクションテストに失敗しました:', error);
    res.status(500).json({
      success: false,
      error: `リアクションテストに失敗しました: ${error.message}`
    });
  }
});

// reactions.get APIをテストするエンドポイント
app.get('/api/test-reactions-direct', async (req, res) => {
  try {
    console.log('リアクション直接取得テストを実行します...');
    
    // テスト用のチャンネルID(最初のチャンネルを使用)
    const testChannelId = slackChannelIds[0];
    console.log(`テスト用チャンネルID: ${testChannelId}`);
    
    // チャンネル情報を取得してログに出力
    const channelInfo = await slackClient.conversations.info({
      channel: testChannelId
    });
    console.log(`テスト用チャンネル名: ${channelInfo.channel.name}`);
    
    // 過去3ヶ月のメッセージを取得
    const threeMonthsAgo = new Date();
    threeMonthsAgo.setMonth(threeMonthsAgo.getMonth() - 3);
    const oldestTimestamp = threeMonthsAgo.getTime() / 1000;
    
    // チャンネル履歴を取得(最新100件のメッセージ)
    const result = await slackClient.conversations.history({
      channel: testChannelId,
      limit: 100,
      oldest: oldestTimestamp
    });
    
    console.log(`メッセージ件数: ${result.messages.length}件`);
    
    // 各メッセージのリアクション情報を取得
    let totalReactions = 0;
    let messagesWithReactions = 0;
    const reactionResults = [];
    
    // データベース接続
    const db = new sqlite3.Database('slack_data.db');
    
    try {
      // トランザクション開始
      await new Promise((resolve, reject) => {
        db.run('BEGIN TRANSACTION', err => {
          if (err) reject(err);
          else resolve();
        });
      });
      
      // リアクションテーブルをクリア
      await new Promise((resolve, reject) => {
        db.run('DELETE FROM slack_reactions', err => {
          if (err) reject(err);
          else resolve();
        });
      });
      
      // プリペアドステートメント作成
      const stmt = db.prepare(`
        INSERT INTO slack_reactions (message_id, user_id, name, timestamp)
        VALUES (?, ?, ?, ?)
      `);
      
      // 最初の10件のメッセージについてリアクション情報を取得
      for (const msg of result.messages.slice(0, 10)) {
        try {
          console.log(`メッセージ確認 (ts=${msg.ts}): テキスト="${msg.text.substring(0, 30)}..."`);
          
          // reactions.get APIを使用してリアクション情報を取得
          const reactionInfo = await slackClient.reactions.get({
            channel: testChannelId,
            timestamp: msg.ts,
            full: true
          });
          
          console.log(`リアクション取得結果:`, JSON.stringify(reactionInfo, null, 2));
          
          if (reactionInfo.message && reactionInfo.message.reactions && reactionInfo.message.reactions.length > 0) {
            messagesWithReactions++;
            
            for (const reaction of reactionInfo.message.reactions) {
              if (reaction.users && reaction.users.length > 0) {
                for (const userId of reaction.users) {
                  // リアクションをDBに保存
                  stmt.run(msg.ts, userId, reaction.name, msg.ts);
                  totalReactions++;
                }
              }
            }
            
            reactionResults.push({
              ts: msg.ts,
              text: msg.text.substring(0, 50) + (msg.text.length > 50 ? '...' : ''),
              reactions: reactionInfo.message.reactions
            });
          }
        } catch (reactionError) {
          console.error(`メッセージ ${msg.ts} のリアクション取得エラー:`, reactionError);
        }
        
        // API制限を考慮して少し待機
        await new Promise(resolve => setTimeout(resolve, 200));
      }
      
      // ステートメントをfinalize
      stmt.finalize();
      
      // トランザクションをコミット
      await new Promise((resolve, reject) => {
        db.run('COMMIT', err => {
          if (err) reject(err);
          else resolve();
        });
      });
      
      console.log(`${totalReactions}件のリアクションをデータベースに保存しました`);
    } catch (dbError) {
      console.error('データベース操作エラー:', dbError);
      
      // エラーが発生した場合はロールバック
      await new Promise((resolve) => {
        db.run('ROLLBACK', () => resolve());
      });
    } finally {
      // データベースを閉じる
      db.close();
    }
    
    // レスポンスを返す
    res.json({
      success: true,
      messageCount: result.messages.length,
      messagesWithReactions: messagesWithReactions,
      totalReactions: totalReactions,
      samples: reactionResults
    });
  } catch (error) {
    console.error('リアクション直接取得テストエラー:', error);
    res.status(500).json({
      success: false,
      error: `リアクション直接取得テストに失敗しました: ${error.message}`
    });
  }
});

// アプリ設定を取得する関数
function getSettings() {
  try {
    // デフォルト設定
    const defaultSettings = {
      aiPrompt: '以下のSlackメッセージからユーザーの特徴を分析してください。簡潔に200文字程度でまとめてください。',
      syncPeriod: 90,  // 90日(3ヶ月)
      characterImage: '/images/brave-character.png',  // デフォルトのキャラクター画像
      slackBotToken: process.env.SLACK_BOT_TOKEN || null,
      slackChannelIds: process.env.SLACK_CHANNEL_IDS ? process.env.SLACK_CHANNEL_IDS.split(',') : [],
      anthropicApiKey: process.env.ANTHROPIC_API_KEY || null,
      openaiApiKey: process.env.OPENAI_API_KEY || null
    };
    
    // 設定ファイルからの読み込みを試みる
    try {
      const settingsFile = fs.readFileSync('settings.json', 'utf8');
      const settings = JSON.parse(settingsFile);
      console.log('設定ファイルから設定を読み込みました');
      return { ...defaultSettings, ...settings };
    } catch (readError) {
      console.log('設定ファイルの読み込みに失敗しました: ' + readError.message);
      return defaultSettings;
    }
  } catch (error) {
    console.error('設定ファイルの読み込みに失敗しました:', error);
    return {
      aiPrompt: '以下のSlackメッセージからユーザーの特徴を分析してください。簡潔に200文字程度でまとめてください。',
      syncPeriod: 90,  // 90日(3ヶ月)
      characterImage: '/images/brave-character.png',  // デフォルトのキャラクター画像
      slackBotToken: process.env.SLACK_BOT_TOKEN || null,
      slackChannelIds: process.env.SLACK_CHANNEL_IDS ? process.env.SLACK_CHANNEL_IDS.split(',') : [],
      anthropicApiKey: process.env.ANTHROPIC_API_KEY || null,
      openaiApiKey: process.env.OPENAI_API_KEY || null
    };
  }
}

// 「今日の再同期」用API
app.get('/api/clean-sync-slack-data', async (req, res) => {
  try {
    console.log('クリーンアップおよび再同期を開始します...');
    
    // 最終同期日時を先に更新(同時実行防止のため)
    try {
      // 日本時間の現在日時を取得
      const jstNow = getJSTDate();
      const timestamp = jstNow.getTime();
      const datetimeStr = jstNow.toISOString().replace('T', ' ').replace('Z', '');
      
      console.log(`同期開始前に最終同期日時を更新します (JST): ${datetimeStr}, ${timestamp}`);
      
      // 既存の最終同期レコードを取得
      const lastSyncData = await new Promise((resolve, reject) => {
        db.get('SELECT * FROM last_sync_date ORDER BY id DESC LIMIT 1', [], (err, row) => {
          if (err) {
            console.error('最終同期データの確認に失敗:', err);
            reject(err);
            return;
          }
          resolve(row);
        });
      });
      
      if (lastSyncData) {
        // 既存のレコードがある場合は更新
        await new Promise((resolve, reject) => {
          db.run(
            'UPDATE last_sync_date SET last_sync = ?, last_sync_timestamp = ? WHERE id = ?', 
            [datetimeStr, timestamp, lastSyncData.id], 
            function(err) {
              if (err) {
                console.error('最終同期日時の更新に失敗:', err);
                reject(err);
              } else {
                console.log(`最終同期日時を更新しました (JST): ${datetimeStr}, ${timestamp}`);
                resolve();
              }
            }
          );
        });
      } else {
        // 既存のレコードがない場合は新規作成
        await new Promise((resolve, reject) => {
          db.run(
            'INSERT INTO last_sync_date (last_sync, last_sync_timestamp) VALUES (?, ?)', 
            [datetimeStr, timestamp], 
            function(err) {
              if (err) {
                console.error('最終同期日時の新規作成に失敗:', err);
                reject(err);
              } else {
                console.log(`最終同期日時を新規作成しました (JST): ${datetimeStr}, ${timestamp}`);
                resolve();
              }
            }
          );
        });
      }
    } catch (syncDateError) {
      console.error('最終同期日時の更新に失敗:', syncDateError);
      // 同期日時の更新に失敗しても処理は続行
    }
    
    // クリーンアップ処理
    try {
      // 今日の日付を取得(日本時間)
      const today = getJSTDateString();
      
      // 今日のイチ推しbraverデータを削除
      await new Promise((resolve, reject) => {
        db.run('DELETE FROM daily_pickup_user WHERE pickup_date = ?', [today], function(err) {
          if (err) {
            console.error('イチ推しbraverリセットエラー:', err);
            reject(err);
          } else {
            console.log(`今日のイチ推しbraverをリセットしました: ${this.changes}件のデータを削除`);
            resolve();
          }
        });
      });
      
      // 1週間前のタイムスタンプを計算(日本時間ベース)
      const oneWeekAgo = getJSTDate();
      oneWeekAgo.setDate(oneWeekAgo.getDate() - 7);
      const oneWeekAgoTimestamp = oneWeekAgo.getTime() / 1000;
      
      console.log(`直近1週間分(${oneWeekAgo.toLocaleString('ja-JP')}以降)のSlackデータを削除します...`);
      
      // 削除処理をPromiseとして実行
      const deleteMessages = new Promise((resolve, reject) => {
        db.run('DELETE FROM slack_messages WHERE timestamp >= ?', [oneWeekAgoTimestamp], function(err) {
          if (err) {
            console.error('直近1週間のメッセージ削除エラー:', err);
            reject(err);
          } else {
            console.log(`直近1週間のメッセージを削除しました: ${this.changes}件のデータを削除`);
            resolve();
          }
        });
      });
      
      const deleteThreadReplies = new Promise((resolve, reject) => {
        db.run('DELETE FROM slack_thread_replies WHERE timestamp >= ?', [oneWeekAgoTimestamp], function(err) {
          if (err) {
            console.error('直近1週間のスレッド返信削除エラー:', err);
            reject(err);
          } else {
            console.log(`直近1週間のスレッド返信を削除しました: ${this.changes}件のデータを削除`);
            resolve();
          }
        });
      });
      
      const deleteReactions = new Promise((resolve, reject) => {
        db.run('DELETE FROM slack_reactions WHERE timestamp >= ?', [oneWeekAgoTimestamp], function(err) {
          if (err) {
            console.error('直近1週間のリアクション削除エラー:', err);
            reject(err);
          } else {
            console.log(`直近1週間のリアクションを削除しました: ${this.changes}件のデータを削除`);
            resolve();
          }
        });
      });
      
      // すべての削除処理を待つ
      await Promise.all([deleteMessages, deleteThreadReplies, deleteReactions]);
      console.log('すべてのデータ削除処理が完了しました');
      
      // 全チャンネルの同期を実行
      if (!slackToken || !slackChannelIds.length) {
        return res.status(500).json({ 
          error: 'Slack APIの設定が不足しています。環境変数を確認してください。' 
        });
      }
      
      const channelsToSync = global.slackChannelIds || slackChannelIds;
      console.log(`設定されている全チャンネル (${channelsToSync.length}件) を同期します: ${channelsToSync.join(', ')}`);
      
      // チャンネルごとの同期結果を保存する配列
      const syncResults = [];
      const errorChannels = [];
      
      // APIレート制限を回避するためのディレイ関数
      const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
      
      // 各チャンネルを順番に処理(レート制限を回避するため順次処理)
      for (const channelId of channelsToSync) {
        try {
          console.log(`チャンネル ${channelId} の同期を開始します...`);
          
          // チャンネル情報を取得 - レート制限対策
          let channelInfo;
          try {
            channelInfo = await slackClient.conversations.info({
              channel: channelId
            });
          } catch (error) {
            if (error.code === 'rate_limited') {
              console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
              await delay((error.retryAfter || 5) * 1000);
              channelInfo = await slackClient.conversations.info({
                channel: channelId
              });
            } else {
              throw error;
            }
          }
          
          const channelName = channelInfo.channel.name || 'Unknown Channel';
          console.log(`チャンネル名: ${channelName}`);
          
          // APIリクエスト間にディレイを挿入してレート制限を回避
          await delay(1000);
          
          // チャンネル履歴を取得 - レート制限対策
          let result;
          try {
            result = await slackClient.conversations.history({
              channel: channelId,
              limit: 100
            });
          } catch (error) {
            if (error.code === 'rate_limited') {
              console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
              await delay((error.retryAfter || 5) * 1000);
              result = await slackClient.conversations.history({
                channel: channelId,
                limit: 100
              });
            } else {
              throw error;
            }
          }
          
          console.log(`チャンネル ${channelName} から ${result.messages.length} 件のメッセージを取得`);
          
          // メッセージを保存
          for (const message of result.messages) {
            // メッセージのタイムスタンプをチェック
            if (message.ts && parseFloat(message.ts) >= oneWeekAgoTimestamp) {
              // メッセージを保存
              const messageType = message.subtype || 'message';
              const userId = message.user || 'unknown_user';
              const text = message.text || '';
              const timestamp = message.ts;
              
              // DBに保存
              db.run(
                'INSERT OR REPLACE INTO slack_messages (message_id, channel_id, channel_name, user_id, text, message_type, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
                [
                  timestamp,
                  channelId,
                  channelName,
                  userId,
                  text,
                  messageType,
                  timestamp,
                  Math.floor(Date.now() / 1000)
                ],
                function(err) {
                  if (err) {
                    console.error(`メッセージ保存エラー: ${err.message}`);
                  }
                }
              );
              
              // スレッド返信があれば取得
              if (message.thread_ts && message.reply_count > 0) {
                console.log(`スレッド検出: message_id=${message.ts}, thread_ts=${message.thread_ts}, reply_count=${message.reply_count}`);
                console.log(`スレッド返信を取得します: thread_ts=${message.thread_ts}, reply_count=${message.reply_count}`);
                
                // APIリクエスト間にディレイを挿入してレート制限を回避
                await delay(1000);
                
                // スレッド返信を取得 - レート制限対策
                let repliesResult;
                try {
                  repliesResult = await slackClient.conversations.replies({
                    channel: channelId,
                    ts: message.thread_ts,
                    limit: 100
                  });
                } catch (error) {
                  if (error.code === 'rate_limited') {
                    console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
                    await delay((error.retryAfter || 5) * 1000);
                    repliesResult = await slackClient.conversations.replies({
                      channel: channelId,
                      ts: message.thread_ts,
                      limit: 100
                    });
                  } else {
                    console.error(`スレッド返信取得エラー: ${error.message}`);
                    continue; // このスレッドをスキップして次へ
                  }
                }
                
                console.log(`スレッド返信取得結果: ${repliesResult.messages.length}件`);
                
                // スレッド返信を保存
                for (const reply of repliesResult.messages) {
                  // 親メッセージは除外
                  if (reply.ts === message.thread_ts) continue;
                  
                  const replyUserId = reply.user || 'unknown_user';
                  const replyText = reply.text || '';
                  const replyTimestamp = reply.ts;
                  
                  // DBに保存
                  db.run(
                    'INSERT OR REPLACE INTO slack_thread_replies (reply_id, thread_ts, message_id, channel_id, channel_name, user_id, text, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
                    [
                      replyTimestamp,
                      message.thread_ts,
                      replyTimestamp,
                      channelId,
                      channelName,
                      replyUserId,
                      replyText,
                      replyTimestamp,
                      Math.floor(Date.now() / 1000)
                    ],
                    function(err) {
                      if (err) {
                        console.error(`スレッド返信保存エラー: ${err.message}`);
                      }
                    }
                  );
                }
                
                // API呼び出し間に短いディレイを入れる
                await delay(500);
              }
              
              // リアクションを処理
              if (message.reactions && message.reactions.length > 0) {
                console.log(`リアクション検出: message_id=${message.ts}, reaction_count=${message.reactions.length}`);
                console.log(`リアクション詳細: ${JSON.stringify(message.reactions, null, 2)}`);
                
                for (const reaction of message.reactions) {
                  const emojiName = reaction.name;
                  const userIds = reaction.users || [];
                  
                  console.log(`リアクション「${emojiName}」のユーザー数: ${userIds.length}`);
                  
                  // ユーザーごとにリアクションを保存
                  for (const reactUserId of userIds) {
                    db.run(
                      'INSERT OR REPLACE INTO slack_reactions (message_id, channel_id, user_id, name, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?)',
                      [
                        message.ts,
                        channelId,
                        reactUserId,
                        emojiName,
                        message.ts,
                        Math.floor(Date.now() / 1000)
                      ],
                      function(err) {
                        if (err) {
                          console.error(`リアクション保存エラー: ${err.message}`);
                        } else {
                          console.log(`リアクション保存成功: message_id=${message.ts}, user=${reactUserId}, emoji=${emojiName}`);
                        }
                      }
                    );
                  }
                }
              } else if (message.reactions === undefined) {
                console.log(`メッセージID: ${message.ts} にはリアクションがありません`);
              }
            }
            
            // API呼び出し間に短いディレイを入れる
            await delay(500);
          }
          
          syncResults.push({
            channelId: channelId,
            channelName: channelName,
            messageCount: result.messages.length,
            success: true
          });
          
          // チャンネルごとに少し長めのディレイを入れる
          await delay(2000);
          
        } catch (channelError) {
          console.error(`チャンネル ${channelId} の同期エラー:`, channelError);
          errorChannels.push(channelId);
          syncResults.push({
            channelId: channelId,
            error: channelError.message,
            success: false
          });
          
          // エラー後も少し待機
          await delay(3000);
        }
      }
      
      // 処理結果の概要を出力
      const allChannelsProcessed = syncResults.length;
      const successChannels = syncResults.filter(r => r.success).length;
      
      console.log(`全チャンネルの同期完了: 成功=${successChannels}件, 失敗=${errorChannels.length}件`);
      
      // 同期完了後にイチ推しbraverを再選出
      try {
        console.log('同期完了後にイチ推しbraverを再選出します');
        await resetAndSelectDailyPickupUser();
        console.log('イチ推しbraverの再選出が完了しました');
      } catch (pickupError) {
        console.error('イチ推しbraver再選出エラー:', pickupError);
      }
      
      return res.json({
        success: true,
        channelCount: allChannelsProcessed,
        errorChannels: errorChannels
      });
    } catch (syncError) {
      console.error(`同期処理エラー: ${syncError.message}`);
      return res.status(500).json({ error: `同期処理エラー: ${syncError.message}` });
    }
  } catch (error) {
    console.error(`クリーンアップ同期エラー: ${error.message}`);
    return res.status(500).json({ error: `クリーンアップ同期エラー: ${error.message}` });
  }
});

// Slackデータを差分同期するAPI
app.get('/api/sync-diff-slack-data', async (req, res) => {
  try {
    console.log('データ同期リクエストを受信しました');
    
    if (!slackToken) {
      console.error('Slackトークンが設定されていません');
      return res.status(500).json({ 
        error: 'Slack APIの設定が不足しています。環境変数を確認してください。' 
      });
    }

    if (!slackChannelIds || !slackChannelIds.length) {
      console.error('SlackチャンネルIDが設定されていません');
      return res.status(500).json({ 
        error: 'SlackチャンネルIDが設定されていません。環境変数を確認してください。' 
      });
    }

    console.log('Slackデータの差分同期を開始...');
    console.log(`設定されているチャンネル数: ${slackChannelIds.length}`);
    console.log(`チャンネルID: ${slackChannelIds.join(', ')}`);
    
    // 同期するチャンネルの指定
    const requestedChannelId = req.query.channelId;
    let channelsToSync = [];
    
    if (requestedChannelId) {
      // 特定のチャンネルが指定された場合
      channelsToSync = [requestedChannelId];
      console.log(`指定されたチャンネル ${requestedChannelId} のみを同期します`);
    } else {
      // すべてのチャンネルを同期
      channelsToSync = global.slackChannelIds || slackChannelIds;
      console.log(`設定されている全チャンネル (${channelsToSync.length}件) を同期します: ${channelsToSync.join(', ')}`);
    }
    
    // チャンネルごとの同期結果を保存する配列
    const syncResults = [];
    
    // 各チャンネルを順番に処理
    for (const channelId of channelsToSync) {
      try {
        console.log(`チャンネル ${channelId} の同期を開始します...`);
        
        // チャンネル情報の取得を試みる
        const channelInfo = await slackClient.conversations.info({
          channel: channelId
        }).catch(error => {
          console.error(`チャンネル ${channelId} の情報取得に失敗:`, error.message);
          throw error;
        });
        
        console.log(`チャンネル ${channelId} (${channelInfo.channel.name}) の同期を続行します`);
        
        // 以下、既存の同期処理を続行
        // ... existing code ...
      } catch (channelSyncError) {
        console.error(`チャンネル ${channelId} の同期エラー:`, channelSyncError);
        syncResults.push({
          channelId: channelId,
          error: channelSyncError.message,
          success: false
        });
      }
    }
    
    // 同期完了後にイチ推しbraverを再選出
    try {
      console.log('同期完了後にイチ推しbraverを再選出します');
      await resetAndSelectDailyPickupUser();
      console.log('イチ推しbraverの再選出が完了しました');
    } catch (pickupError) {
      console.error('イチ推しbraver再選出エラー:', pickupError);
    }
    
    return res.json({
      success: true,
      syncedChannels: syncResults,
      channelCount: syncResults.length,
      period: `${getJSTDate().toLocaleDateString('ja-JP')} までの3ヶ月分`,
      message: "スタンプ情報も含めて全データを同期しました"
    });
  } catch (error) {
    console.error('Slackデータ差分同期エラー:', error);
    return res.status(500).json({ error: `同期処理中にエラーが発生しました: ${error.message}` });
  }
});

// 朝の自動同期用API
app.get('/api/morning-sync', async (req, res) => {
  try {
    console.log('朝の自動同期を開始します...');
    
    // 環境変数の確認
    if (!slackToken) {
      console.error('Slackトークンが設定されていません');
      return res.status(500).json({ 
        error: 'Slack APIの設定が不足しています。環境変数を確認してください。' 
      });
    }

    if (!slackChannelIds || !slackChannelIds.length) {
      console.error('SlackチャンネルIDが設定されていません');
      return res.status(500).json({ 
        error: 'SlackチャンネルIDが設定されていません。環境変数を確認してください。' 
      });
    }

    // データベースの接続確認
    db.get("SELECT 1", (err) => {
      if (err) {
        console.error('データベース接続エラー:', err);
        return res.status(500).json({ 
          error: 'データベース接続に失敗しました。' 
        });
      }
    });

    // 最終同期日時を確認して、24時間以上経過していれば同期を実行
    try {
      // 現在の時刻(日本時間)
      const currentTime = getJSTDate().getTime();
      
      // 最終同期日時を取得
      const lastSyncData = await new Promise((resolve, reject) => {
        db.get('SELECT * FROM last_sync_date ORDER BY id DESC LIMIT 1', [], (err, row) => {
          if (err) {
            console.error('最終同期データの確認に失敗:', err);
            reject(err);
            return;
          }
          resolve(row);
        });
      });
      
      // 同期が必要かどうかを判断
      let needSync = false;
      let reason = "";
      
      if (!lastSyncData) {
        console.log('初回アクセス: 同期データがありません');
        needSync = true;
        reason = "初回アクセスのため";
      } else {
        // 最終同期タイムスタンプから24時間以上経過しているか確認
        const lastSyncTimestamp = Number(lastSyncData.last_sync_timestamp);
        if (isNaN(lastSyncTimestamp)) {
          console.log('タイムスタンプがNaNです:', lastSyncData.last_sync_timestamp);
          needSync = true;
          reason = "有効なタイムスタンプがないため";
        } else {
          const timeDiff = currentTime - lastSyncTimestamp;
          const hoursDiff = timeDiff / (1000 * 60 * 60); // ミリ秒 → 時間
          
          console.log(`前回の同期から${hoursDiff.toFixed(2)}時間経過しています`);
          console.log(`最終同期タイムスタンプ: ${lastSyncTimestamp} (${new Date(lastSyncTimestamp).toISOString()})`);
          console.log(`現在のタイムスタンプ (JST): ${currentTime} (${new Date(currentTime).toISOString()})`);
          console.log(`時間差: ${timeDiff}ms = ${hoursDiff}時間`);
          
          // 24時間以上経過している場合は同期が必要
          if (hoursDiff >= 24) {
            needSync = true;
            reason = `前回の同期から${Math.floor(hoursDiff)}時間経過したため`;
            console.log(`同期条件成立: ${reason}`);
          } else {
            console.log('前回の同期から24時間経過していないため同期をスキップします');
            return res.json({
              success: true,
              message: "前回の同期から24時間経過していないため同期をスキップしました",
              lastSync: new Date(lastSyncTimestamp).toISOString(),
              hoursSinceLastSync: hoursDiff.toFixed(2)
            });
          }
        }
      }
      
      // 同期が必要な場合のみ実行
      if (needSync) {
        console.log(`同期を実行します(理由: ${reason})...`);
        
        // clean-sync-slack-dataエンドポイントを内部で呼び出す
        console.log('clean-sync-slack-dataエンドポイントを内部で呼び出します...');
        
        const response = await fetch(`http://localhost:${port}/api/clean-sync-slack-data`, {
          method: 'GET'
        });
        
        if (!response.ok) {
          const errorText = await response.text();
          throw new Error(`clean-sync-slack-dataの呼び出しに失敗しました: ${response.status} ${errorText}`);
        }
        
        const result = await response.json();
        
        // clean-sync-slack-dataの結果をそのまま返す
        console.log('朝の自動同期が完了しました');
        return res.json({
          ...result,
          message: "朝の自動同期が完了しました",
          reason: reason
        });
      }
    } catch (syncError) {
      console.error('clean-sync-slack-data呼び出しエラー:', syncError);
      return res.status(500).json({ 
        error: `同期処理中にエラーが発生しました: ${syncError.message}` 
      });
    }
  } catch (error) {
    console.error('朝の自動同期エラー:', error);
    return res.status(500).json({ 
      error: `同期処理中にエラーが発生しました: ${error.message}` 
    });
  }
});

// 最終同期日時をチェックし、必要に応じて同期を実行する関数
async function checkAndSyncIfNeeded() {
  console.log('\n==========================');
  console.log('checkAndSyncIfNeeded関数が呼び出されました');
  console.log('==========================\n');
  
  try {
    console.log('\n======== 自動同期チェック開始 ========');
    const today = getJSTDateString();
    const currentTime = getJSTDate().getTime();
    
    console.log(`今日の日付 (JST): ${today}`);
    console.log(`現在のタイムスタンプ (JST): ${currentTime}`);
    
    // DBから最終同期日時を確認
    try {
      const lastSyncData = await new Promise((resolve, reject) => {
        db.get('SELECT * FROM last_sync_date ORDER BY id DESC LIMIT 1', [], (err, row) => {
          if (err) {
            console.error('最終同期データの確認に失敗:', err);
            reject(err);
            return;
          }
          
          if (row) {
            console.log('最終同期データ:', JSON.stringify(row, null, 2));
          } else {
            console.log('最終同期データが存在しません');
          }
          resolve(row);
        });
      });
      
      // 同期が必要かどうかを判断
      let needSync = false;
      let reason = "";
      
      if (!lastSyncData) {
        console.log('初回アクセス: 同期データがありません');
        // 初回実行
        needSync = true;
        reason = "初回アクセスのため";
      } else {
        // 最終同期タイムスタンプから24時間以上経過しているか確認
        const lastSyncTimestamp = Number(lastSyncData.last_sync_timestamp);
        if (isNaN(lastSyncTimestamp)) {
          console.log('タイムスタンプがNaNです:', lastSyncData.last_sync_timestamp);
          needSync = true;
          reason = "有効なタイムスタンプがないため";
        } else {
          const timeDiff = currentTime - lastSyncTimestamp;
          const hoursDiff = timeDiff / (1000 * 60 * 60); // ミリ秒 → 時間
          
          console.log(`前回の同期から${hoursDiff.toFixed(2)}時間経過しています`);
          console.log(`最終同期タイムスタンプ: ${lastSyncTimestamp} (${new Date(lastSyncTimestamp).toISOString()})`);
          console.log(`現在のタイムスタンプ (JST): ${currentTime} (${new Date(currentTime).toISOString()})`);
          console.log(`時間差: ${timeDiff}ms = ${hoursDiff}時間`);
          
          // 24時間以上経過している場合は同期が必要
          if (hoursDiff >= 24) {
            needSync = true;
            reason = `前回の同期から${Math.floor(hoursDiff)}時間経過したため`;
            console.log(`自動同期条件成立: ${reason}`);
          } else {
            console.log('前回の同期から24時間経過していないためスキップします');
            
            // 最終同期日時の取得(YYYY-MM-DD形式に変換)
            let lastSyncDate;
            try {
              lastSyncDate = new Date(lastSyncData.last_sync);
              const lastSyncDateStr = lastSyncDate.toISOString().split('T')[0];
              console.log(`最終同期日: ${lastSyncDateStr}, 今日 (JST): ${today}`);
              
              // 日付が変わった場合はイチ推しBraverの選出のみ行う
              if (lastSyncDateStr !== today) {
                console.log('最終同期日が今日ではありません。イチ推しBraverを選出します...');
                try {
                  await resetAndSelectDailyPickupUser();
                  
                  // 最終同期日も更新(日本時間)
                  const jstNow = getJSTDate();
                  const timestamp = jstNow.getTime();
                  const datetimeStr = jstNow.toISOString().replace('T', ' ').replace('Z', '');
                  
                  console.log(`最終同期日を更新します (JST): ${datetimeStr}, ${timestamp}`);
                  await new Promise((resolve, reject) => {
                    db.run(
                      'UPDATE last_sync_date SET last_sync = ?, last_sync_timestamp = ? WHERE id = ?', 
                      [datetimeStr, timestamp, lastSyncData.id], 
                      function(err) {
                        if (err) {
                          console.error('最終同期日の更新に失敗:', err);
                          reject(err);
                        } else {
                          console.log(`最終同期日を更新しました (JST): ${datetimeStr}, ${timestamp}`);
                          resolve();
                        }
                      }
                    );
                  });
                } catch (syncError) {
                  console.error('イチ推しbraver選出の実行に失敗:', syncError);
                }
              }
            } catch (dateError) {
              console.error('日付変換エラー:', dateError, lastSyncData.last_sync);
            }
            
            return; // 同期不要の場合は処理を終了
          }
        }
      }
      
      // 同期が必要な場合は実行
      if (needSync) {
        console.log(`自動同期を開始します(理由: ${reason})...`);
        
        try {
          // clean-sync-slack-dataの処理を呼び出す
          console.log(`同期APIを呼び出します: http://localhost:${port}/api/clean-sync-slack-data`);
          const response = await fetch(`http://localhost:${port}/api/clean-sync-slack-data`, {
            method: 'GET'
          });
          
          if (!response.ok) {
            const errorText = await response.text();
            throw new Error(`同期エラー: ${response.status} ${response.statusText} - ${errorText}`);
          }
          
          const data = await response.json();
          console.log('自動同期完了:', data);
          
          // 最終同期日時を更新(日本時間)
          const jstNow = getJSTDate();
          const timestamp = jstNow.getTime();
          const datetimeStr = jstNow.toISOString().replace('T', ' ').replace('Z', '');
          
          console.log(`同期後に最終同期日時を更新 (JST): ${datetimeStr}, ${timestamp}`);
          await new Promise((resolve, reject) => {
            db.run(
              'UPDATE last_sync_date SET last_sync = ?, last_sync_timestamp = ? WHERE id = ?', 
              [datetimeStr, timestamp, lastSyncData ? lastSyncData.id : 1], 
              function(err) {
                if (err) {
                  console.error('最終同期日時の更新に失敗:', err);
                  reject(err);
                } else {
                  console.log(`最終同期日時を更新しました (JST): ${datetimeStr}, ${timestamp}`);
                  resolve();
                }
              }
            );
          });
        } catch (syncError) {
          console.error('自動同期の実行に失敗:', syncError);
        }
      }
    } catch (dbError) {
      console.error('データベース操作エラー:', dbError);
    }
    
    console.log('======== 自動同期チェック終了 ========\n');
  } catch (error) {
    console.error('同期チェック処理でエラーが発生:', error);
  }
}

// メインページへのアクセス時に同期チェックを実行
app.get('/', async (req, res) => {
  console.log('ルートパスにアクセスがありました - checkAndSyncIfNeeded関数を呼び出します');
  try {
    await checkAndSyncIfNeeded();
    console.log('checkAndSyncIfNeeded関数の実行が完了しました');
  } catch (error) {
    console.error('checkAndSyncIfNeeded関数の実行中にエラーが発生しました:', error);
  }
  res.sendFile(path.join(__dirname, 'public', 'index.html'));
});

// その他のページアクセス時にも同期チェックを実行
app.get('/simple', async (req, res) => {
  await checkAndSyncIfNeeded();
  res.sendFile(path.join(__dirname, 'public', 'simple.html'));
});

// ... existing code ...

// 設定を保存する関数
function saveSettings(settings) {
  try {
    fs.writeFileSync(SETTINGS_FILE_PATH, JSON.stringify(settings, null, 2), 'utf8');
    console.log('設定ファイルを保存しました');
    return true;
  } catch (error) {
    console.error('設定ファイル保存エラー:', error);
    return false;
  }
}

// ... existing code ...

// Google Sheets APIの設定
const SCOPES = ['https://www.googleapis.com/auth/spreadsheets.readonly'];
const SPREADSHEET_ID = process.env.GOOGLE_SHEET_ID;
const SHEET_NAME = '営業状況';

// Google Sheets APIクライアントの初期化
async function initGoogleSheetsClient() {
  try {
    const auth = new JWT({
      email: process.env.GOOGLE_CLIENT_EMAIL,
      key: process.env.GOOGLE_PRIVATE_KEY.replace(/\\n/g, '\n'),
      scopes: SCOPES,
    });

    const sheets = google.sheets({ version: 'v4', auth });
    return sheets;
  } catch (error) {
    console.error('Google Sheets APIクライアントの初期化に失敗しました:', error);
    throw error;
  }
}

// 営業データの取得
async function getSalesData() {
  try {
    const sheets = await initGoogleSheetsClient();
    const response = await sheets.spreadsheets.values.get({
      spreadsheetId: SPREADSHEET_ID,
      range: `${SHEET_NAME}!A:Z`,
    });

    const rows = response.data.values;
    if (!rows || rows.length === 0) {
      console.log('データが見つかりませんでした。');
      return [];
    }

    // ヘッダー行を取得
    const headers = rows[0];
    const data = rows.slice(1).map(row => {
      const item = {};
      headers.forEach((header, index) => {
        item[header] = row[index] || '';
      });
      return item;
    });

    return data;
  } catch (error) {
    console.error('営業データの取得に失敗しました:', error);
    throw error;
  }
}

// 営業データを取得するAPIエンドポイント
app.get('/api/sales-data', async (req, res) => {
  try {
    const salesData = await getSalesData();
    res.json(salesData);
  } catch (error) {
    console.error('営業データの取得に失敗しました:', error);
    res.status(500).json({ error: '営業データの取得に失敗しました' });
  }
});
// ... existing code ...

// 「直近3ヶ月のデータ同期」用API
app.get('/api/sync-three-months-data', async (req, res) => {
  try {
    console.log('直近3ヶ月のデータ同期を開始します...');
    
    // クリーンアップ処理
    try {
      // 3ヶ月前のタイムスタンプを計算(日本時間ベース)
      const threeMonthsAgo = getJSTDate();
      threeMonthsAgo.setDate(threeMonthsAgo.getDate() - 90);
      const threeMonthsAgoTimestamp = threeMonthsAgo.getTime() / 1000;
      
      console.log(`直近3ヶ月分(${threeMonthsAgo.toLocaleString('ja-JP')}以降)のSlackデータを削除します...`);
      
      // 削除処理をPromiseとして実行
      const deleteMessages = new Promise((resolve, reject) => {
        db.run('DELETE FROM slack_messages WHERE timestamp >= ?', [threeMonthsAgoTimestamp], function(err) {
          if (err) {
            console.error('直近3ヶ月のメッセージ削除エラー:', err);
            reject(err);
          } else {
            console.log(`直近3ヶ月のメッセージを削除しました: ${this.changes}件のデータを削除`);
            resolve();
          }
        });
      });
      
      const deleteThreadReplies = new Promise((resolve, reject) => {
        db.run('DELETE FROM slack_thread_replies WHERE timestamp >= ?', [threeMonthsAgoTimestamp], function(err) {
          if (err) {
            console.error('直近3ヶ月のスレッド返信削除エラー:', err);
            reject(err);
          } else {
            console.log(`直近3ヶ月のスレッド返信を削除しました: ${this.changes}件のデータを削除`);
            resolve();
          }
        });
      });
      
      const deleteReactions = new Promise((resolve, reject) => {
        db.run('DELETE FROM slack_reactions WHERE timestamp >= ?', [threeMonthsAgoTimestamp], function(err) {
          if (err) {
            console.error('直近3ヶ月のリアクション削除エラー:', err);
            reject(err);
          } else {
            console.log(`直近3ヶ月のリアクションを削除しました: ${this.changes}件のデータを削除`);
            resolve();
          }
        });
      });
      
      // すべての削除処理を待つ
      await Promise.all([deleteMessages, deleteThreadReplies, deleteReactions]);
      console.log('すべてのデータ削除処理が完了しました');
      
      // 全チャンネルの同期を実行
      if (!slackToken || !slackChannelIds.length) {
        return res.status(500).json({ 
          error: 'Slack APIの設定が不足しています。環境変数を確認してください。' 
        });
      }
      
      const channelsToSync = global.slackChannelIds || slackChannelIds;
      console.log(`設定されている全チャンネル (${channelsToSync.length}件) を同期します: ${channelsToSync.join(', ')}`);
      
      // チャンネルごとの同期結果を保存
      const syncResults = [];
      const errorChannels = [];
      let totalMessageCount = 0;
      let totalReplyCount = 0;
      let totalReactionCount = 0;
      
      // APIレート制限を回避するためのディレイ関数
      const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
      
      // 各チャンネルを順番に処理(レート制限を回避するため順次処理)
      for (const channelId of channelsToSync) {
        try {
          console.log(`チャンネル ${channelId} の同期を開始します...`);
          
          // チャンネル情報を取得 - レート制限対策
          let channelInfo;
          try {
            channelInfo = await slackClient.conversations.info({
              channel: channelId
            });
          } catch (error) {
            if (error.code === 'rate_limited') {
              console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
              await delay((error.retryAfter || 5) * 1000);
              channelInfo = await slackClient.conversations.info({
                channel: channelId
              });
            } else {
              throw error;
            }
          }
          
          const channelName = channelInfo.channel.name || 'Unknown Channel';
          console.log(`チャンネル名: ${channelName}`);
          
          // APIリクエスト間にディレイを挿入してレート制限を回避
          await delay(1000);
          
          let cursor = undefined;
          let messageCount = 0;
          let replyCount = 0;
          let reactionCount = 0;
          let hasMore = true;
          
          // ページネーションを使用して3ヶ月分のメッセージを取得
          while (hasMore) {
            // チャンネル履歴を取得 - レート制限対策
            let result;
            try {
              result = await slackClient.conversations.history({
                channel: channelId,
                limit: 100,
                cursor: cursor,
                oldest: threeMonthsAgoTimestamp
              });
            } catch (error) {
              if (error.code === 'rate_limited') {
                console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
                await delay((error.retryAfter || 5) * 1000);
                result = await slackClient.conversations.history({
                  channel: channelId,
                  limit: 100,
                  cursor: cursor,
                  oldest: threeMonthsAgoTimestamp
                });
              } else {
                throw error;
              }
            }
            
            console.log(`チャンネル ${channelName} から ${result.messages.length} 件のメッセージを取得`);
            
            // メッセージを保存
            for (const message of result.messages) {
              // メッセージのタイムスタンプをチェック
              if (message.ts && parseFloat(message.ts) >= threeMonthsAgoTimestamp) {
                // メッセージを保存
                const messageType = message.subtype || 'message';
                const userId = message.user || 'unknown_user';
                const text = message.text || '';
                const timestamp = message.ts;
                
                // DBに保存
                db.run(
                  'INSERT OR REPLACE INTO slack_messages (message_id, channel_id, channel_name, user_id, text, message_type, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
                  [
                    timestamp,
                    channelId,
                    channelName,
                    userId,
                    text,
                    messageType,
                    timestamp,
                    Math.floor(Date.now() / 1000)
                  ],
                  function(err) {
                    if (err) {
                      console.error(`メッセージ保存エラー: ${err.message}`);
                    } else {
                      messageCount++;
                    }
                  }
                );
                
                // スレッド返信があれば取得
                if (message.thread_ts && message.reply_count > 0) {
                  console.log(`スレッド検出: message_id=${message.ts}, thread_ts=${message.thread_ts}, reply_count=${message.reply_count}`);
                  console.log(`スレッド返信を取得します: thread_ts=${message.thread_ts}, reply_count=${message.reply_count}`);
                  
                  // APIリクエスト間にディレイを挿入してレート制限を回避
                  await delay(1000);
                  
                  // スレッド返信を取得 - レート制限対策
                  let repliesResult;
                  try {
                    repliesResult = await slackClient.conversations.replies({
                      channel: channelId,
                      ts: message.thread_ts,
                      limit: 100
                    });
                  } catch (error) {
                    if (error.code === 'rate_limited') {
                      console.log(`レート制限により待機します: ${error.retryAfter || 5}秒`);
                      await delay((error.retryAfter || 5) * 1000);
                      repliesResult = await slackClient.conversations.replies({
                        channel: channelId,
                        ts: message.thread_ts,
                        limit: 100
                      });
                    } else {
                      throw error;
                    }
                  }
                  
                  console.log(`スレッド返信を ${repliesResult.messages.length - 1} 件取得しました`);
                  
                  // 最初のメッセージを除外して返信のみを処理
                  const replies = repliesResult.messages.slice(1);
                  
                  for (const reply of replies) {
                    const replyUserId = reply.user || 'unknown_user';
                    const replyText = reply.text || '';
                    const replyTimestamp = reply.ts;
                    
                    // 返信のタイムスタンプをチェック
                    if (replyTimestamp && parseFloat(replyTimestamp) >= threeMonthsAgoTimestamp) {
                      // DBに保存
                      db.run(
                        'INSERT OR REPLACE INTO slack_thread_replies (reply_id, thread_ts, message_id, channel_id, channel_name, user_id, text, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
                        [
                          replyTimestamp,
                          message.thread_ts,
                          replyTimestamp,
                          channelId,
                          channelName,
                          replyUserId,
                          replyText,
                          replyTimestamp,
                          Math.floor(Date.now() / 1000)
                        ],
                        function(err) {
                          if (err) {
                            console.error(`スレッド返信保存エラー: ${err.message}`);
                          } else {
                            replyCount++;
                          }
                        }
                      );
                    }
                  }
                }
                
                
                // リアクションがあれば処理
                if (message.reactions && message.reactions.length > 0) {
                  console.log(`リアクション検出: message_id=${message.ts}, reaction_count=${message.reactions.length}`);
                  
                  for (const reaction of message.reactions) {
                    const emojiName = reaction.name;
                    const userIds = reaction.users || [];
                    
                    console.log(`  絵文字: ${emojiName}, 付けたユーザー数: ${userIds.length}`);
                    
                    // 各ユーザーのリアクションを保存
                    for (const reactUserId of userIds) {
                      db.run(
                        'INSERT OR REPLACE INTO slack_reactions (message_id, channel_id, user_id, name, timestamp, created_at) VALUES (?, ?, ?, ?, ?, ?)',
                        [
                          message.ts,
                          channelId,
                          reactUserId,
                          emojiName,
                          message.ts,
                          Math.floor(Date.now() / 1000)
                        ],
                        function(err) {
                          if (err) {
                            console.error(`リアクション保存エラー: ${err.message}`);
                          } else {
                            reactionCount++;
                          }
                        }
                      );
                    }
                  }
                }
              }
            }
            
            // 次のページがあるか確認
            cursor = result.response_metadata?.next_cursor;
            hasMore = !!cursor;
            
            // ページネーションがある場合、APIレート制限を回避するためのディレイ
            if (hasMore) {
              console.log(`次のページがあります。次のカーソル: ${cursor}`);
              await delay(1000);
            }
          }
          
          totalMessageCount += messageCount;
          totalReplyCount += replyCount;
          totalReactionCount += reactionCount;
          
          console.log(`チャンネル ${channelName} の同期が完了しました`);
          syncResults.push({
            channelId,
            channelName,
            messageCount,
            replyCount,
            reactionCount
          });
          
          // 次のチャンネルを処理する前にディレイ
          await delay(2000);
          
        } catch (error) {
          console.error(`チャンネル ${channelId} の同期中にエラーが発生しました:`, error);
          errorChannels.push({
            channelId,
            error: error.message
          });
        }
      }
      
      // 最終結果をログに出力
      console.log('=== 3ヶ月分のデータ同期が完了しました ===');
      console.log(`取得されたメッセージ総数: ${totalMessageCount}`);
      console.log(`取得されたスレッド返信総数: ${totalReplyCount}`);
      console.log(`取得されたリアクション総数: ${totalReactionCount}`);
      
      if (errorChannels.length > 0) {
        console.log(`エラーが発生したチャンネル数: ${errorChannels.length}`);
        errorChannels.forEach(ch => {
          console.log(`- チャンネル ${ch.channelId}: ${ch.error}`);
        });
      }
      
      return res.json({
        success: true,
        message: '3ヶ月分のデータ同期が完了しました',
        messageCount: totalMessageCount,
        replyCount: totalReplyCount,
        reactionCount: totalReactionCount,
        syncResults,
        errorChannels
      });
      
    } catch (cleanupError) {
      console.error('データ同期中にエラーが発生しました:', cleanupError);
      return res.status(500).json({ error: `データ同期エラー: ${cleanupError.message}` });
    }
  } catch (error) {
    console.error('3ヶ月分のデータ同期中にエラーが発生しました:', error);
    return res.status(500).json({ error: `API実行エラー: ${error.message}` });
  }
});

// ... existing code ...

// 最終同期日時情報を返すAPI
app.get('/api/last-sync-info', (req, res) => {
  db.get('SELECT last_sync, last_sync_timestamp FROM last_sync_date ORDER BY id DESC LIMIT 1', (err, row) => {
    if (err) {
      console.error('最終同期データの取得に失敗:', err);
      return res.status(500).json({ error: '最終同期情報の取得に失敗しました' });
    }
    
    if (!row) {
      return res.json({ 
        last_sync: null, 
        last_sync_timestamp: null,
        formatted_date: '未同期'
      });
    }
    
    let formattedDate = '未同期';
    
    if (row.last_sync) {
      try {
        // DB内のタイムスタンプは既に日本時間になっているので、そのまま使用
        const syncDate = new Date(row.last_sync);
        const options = { 
          year: 'numeric',
          month: '2-digit', 
          day: '2-digit',
          hour: '2-digit',
          minute: '2-digit',
          timeZone: 'Asia/Tokyo' // 明示的に日本時間を指定
        };
        formattedDate = syncDate.toLocaleString('ja-JP', options);
        console.log(`最終同期日時を日本時間でフォーマット: ${formattedDate}`);
      } catch (e) {
        console.error('日付の変換エラー:', e);
        formattedDate = row.last_sync;
      }
    }
    
    res.json({
      last_sync: row.last_sync,
      last_sync_timestamp: row.last_sync_timestamp,
      formatted_date: formattedDate
    });
  });
});

// ... existing code ...

// 特定ユーザーの詳細情報を取得するAPI
app.get('/api/users/:userId', async (req, res) => {
  try {
    const userId = req.params.userId;
    console.log(`ユーザー詳細情報リクエスト: ${userId}`);
    
    // 詳細なユーザー情報を取得するSQL
    const query = `
      SELECT 
        u.user_id, 
        u.name, 
        u.real_name, 
        u.display_name, 
        u.avatar,
        u.is_bot,
        (SELECT COUNT(*) FROM slack_messages WHERE user_id = u.user_id) AS message_count,
        (SELECT COUNT(*) FROM slack_thread_replies WHERE user_id = u.user_id) AS reply_count,
        (
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND (text LIKE '/yolo%' OR text LIKE '/Yolo%')
        ) AS attendance_count,
        (
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND (text LIKE '/yolo_office%' OR text LIKE '/Yolo_office%')
        ) AS office_count,
        (
          SELECT COUNT(*) FROM slack_messages 
          WHERE user_id = u.user_id 
          AND length(text) >= 50
        ) AS weekly_report_count,
        (
          SELECT COUNT(*) FROM slack_reactions
          WHERE user_id = u.user_id
        ) AS sent_reaction_count,
        (
          SELECT COUNT(*) FROM slack_reactions r
          JOIN slack_messages m ON r.message_id = m.message_id
          WHERE m.user_id = u.user_id
        ) AS received_reaction_count
      FROM 
        slack_users u
      WHERE 
        u.user_id = ?
    `;
    
    db.get(query, [userId], async (err, user) => {
      if (err) {
        console.error('ユーザー詳細情報取得エラー:', err);
        return res.status(500).json({ error: 'ユーザー詳細情報の取得に失敗しました' });
      }
      
      if (!user) {
        return res.status(404).json({ error: 'ユーザーが見つかりません' });
      }
      
      // 追加情報を取得
      try {
        // 週報・発言リスト(最新20件)を取得 - 50文字以上の長文投稿を取得
        const reportsQuery = `
          SELECT 
            m.text, 
            m.timestamp, 
            m.channel_name
          FROM 
            slack_messages m
          WHERE 
            m.user_id = ? 
            AND length(m.text) >= 50
          ORDER BY 
            m.timestamp DESC
          LIMIT 20
        `;
        
        // 返信リスト(最新20件)を取得
        const repliesQuery = `
          SELECT 
            r.text, 
            r.timestamp, 
            r.channel_name, 
            (SELECT text FROM slack_messages WHERE message_id = r.thread_ts) AS parent_text
          FROM 
            slack_thread_replies r
          WHERE 
            r.user_id = ?
          ORDER BY 
            r.timestamp DESC
          LIMIT 20
        `;
        
        // 送信したスタンプ(最新20件)を取得
        const sentStampsQuery = `
          SELECT 
            r.name AS reaction, 
            r.timestamp, 
            m.text AS message_text,
            (SELECT real_name FROM slack_users WHERE user_id = m.user_id) AS target_user
          FROM 
            slack_reactions r
          JOIN 
            slack_messages m ON r.message_id = m.message_id
          WHERE 
            r.user_id = ?
          ORDER BY 
            r.timestamp DESC
          LIMIT 20
        `;
        
        // 受信したスタンプ(最新20件)を取得
        const receivedStampsQuery = `
          SELECT 
            r.name AS reaction, 
            r.timestamp, 
            m.text AS message_text,
            (SELECT real_name FROM slack_users WHERE user_id = r.user_id) AS from_user
          FROM 
            slack_reactions r
          JOIN 
            slack_messages m ON r.message_id = m.message_id
          WHERE 
            m.user_id = ?
          ORDER BY 
            r.timestamp DESC
          LIMIT 20
        `;
        
        // すべてのクエリを並行して実行
        const [reports, replies, sentStamps, receivedStamps] = await Promise.all([
          new Promise((resolve, reject) => {
            db.all(reportsQuery, [userId], (err, rows) => {
              if (err) {
                console.error('週報・発言履歴取得エラー:', err);
                resolve([]);
              } else {
                console.log(`長文投稿履歴 ${rows.length}件取得:`);
                resolve(rows);
              }
            });
          }),
          new Promise((resolve, reject) => {
            db.all(repliesQuery, [userId], (err, rows) => {
              if (err) {
                console.error('返信履歴取得エラー:', err);
                resolve([]);
              } else {
                console.log(`返信履歴 ${rows.length}件取得`);
                resolve(rows);
              }
            });
          }),
          new Promise((resolve, reject) => {
            db.all(sentStampsQuery, [userId], (err, rows) => {
              if (err) {
                console.error('送信スタンプ履歴取得エラー:', err);
                resolve([]);
              } else {
                console.log(`送信スタンプ履歴 ${rows.length}件取得`);
                resolve(rows);
              }
            });
          }),
          new Promise((resolve, reject) => {
            db.all(receivedStampsQuery, [userId], (err, rows) => {
              if (err) {
                console.error('受信スタンプ履歴取得エラー:', err);
                resolve([]);
              } else {
                console.log(`受信スタンプ履歴 ${rows.length}件取得`);
                resolve(rows);
              }
            });
          })
        ]);
        
        // 結果をユーザーオブジェクトに追加
        user.reports = reports.map(report => ({
          ...report,
          date: new Date(parseFloat(report.timestamp) * 1000).toISOString(),
          text_preview: report.text && report.text.length > 200 ? report.text.substring(0, 200) + '...' : report.text
        }));
        
        user.replies = replies.map(reply => ({
          ...reply,
          date: new Date(parseFloat(reply.timestamp) * 1000).toISOString(),
          text_preview: reply.text && reply.text.length > 100 ? reply.text.substring(0, 100) + '...' : reply.text,
          parent_preview: reply.parent_text && reply.parent_text.length > 50 ? reply.parent_text.substring(0, 50) + '...' : reply.parent_text
        }));
        
        user.sent_stamps = sentStamps.map(stamp => ({
          ...stamp,
          date: new Date(parseFloat(stamp.timestamp) * 1000).toISOString(),
          message_preview: stamp.message_text && stamp.message_text.length > 50 ? stamp.message_text.substring(0, 50) + '...' : stamp.message_text
        }));
        
        user.received_stamps = receivedStamps.map(stamp => ({
          ...stamp,
          date: new Date(parseFloat(stamp.timestamp) * 1000).toISOString(),
          message_preview: stamp.message_text && stamp.message_text.length > 50 ? stamp.message_text.substring(0, 50) + '...' : stamp.message_text
        }));
      } catch (dataError) {
        console.error('追加データ取得エラー:', dataError);
        // エラーが発生しても、基本データは返す
      }
      
      // 最終的なユーザー情報を返す
      res.json(user);
    });
    
  } catch (error) {
    console.error('ユーザー詳細取得時のエラー:', error);
    res.status(500).json({ error: 'ユーザー詳細情報の取得に失敗しました' });
  }
});

挑戦者求ム

AIの新時代に全力で挑戦したい人を募集中です!

「開発言語は日本語で」GPT Builderでイベント検索カスタムAIを作ってGPTsに公開! #35

GPTs公開中

AI(特にChatGPT)の進化がオモシロイ!

1年前、OpenAIのAPIでボケてのLINEアプリを作った。その後の2023/11にGPTs(GPTストア)が誕生。誰でも簡単にChatGPTアプリを作って公開できるようになった。

それならやってみよう!ってことでさっそくGPTsにカスタムAIをリリースしてみた。
↑の画像にあるように、ChatGPTユーザなら左メニューから「GPTを探す」→「イベント」を検索すると「イベントニュース」が出てくる。(現時点で課金ユーザのみ)

動いている動画はこちら。自然な言葉で質問できてすぐに回答が来る。(コードも表示するデバッグモード。ほんとにリアルタイムにコードを生成しててオモシロイ!)

開発の流れを紹介

開発の流れをさらっと紹介する。もはやプログラミング言語は必要はない。日本語で簡単にオリジナルAIが作れる時代。実際やってみて、こんなに簡単にできるのかっ!っていう部分と、意外と大変、、という部分があったのであわせて解説しよう。

イベントニュースのデータを活用

今回つくるのはイベント検索AI。ということで当社が運営する(これまたGPTベースの)サービス「イベントニュース」のデータを使う。
イベントニュース|今日行けるイベントを検索|東京,大阪,名古屋,・・全国の情報を掲載

イベントニュースとは、ネットから日本全国の1万件以上のイベントデータを収集。地域や日付から検索したり、AIが新しいイベントをオススメしてくれるサービス。

GPTsリリースまでの大まかな流れ

GPTsはGPTのAppStoreのようなもの。誰でも簡単に自作AIがアップできる。(ただしOpenAIに課金している必要あり)。

リリースまでの大まかな流れはこんな感じ

①ChatGPTからマイGPTを作成する
②タイトルやアイコンなど基本設定
③イベントデータ(CSV)のアップロード
④指示(Instruction)をひたすら編集してチューニング
⑤GPTsへのリリース

リリースまでとにかく簡単。唯一時間がかかるのは④のInstructionの編集。いろいろと試行錯誤して結果的に20時間ぐらいかかった。とはいえ3日程度で1つのサービスが作れたので、簡単は簡単。それぞれの工程をざっと説明する。

①ChatGPTからマイGPTを作成する

まずはマイGPTを作ろう、ということで、左下のアカウント設定から「マイGPT」を選び、「create a GPT」から新しいカスタムAIを作成します。

MyGPTからカスタムAIを作成する

②タイトルやアイコンなど基本設定

カスタムAIの基本的な設定。GPT Builderというツールで、ChatGPTと対話する中で設定可能。また、タイトルなどからアイコンも自動生成してくる。

GPT Builderで基本設定

が、最初は便利だと思ったけど、普通に右のタブのConfigure(構成)のメニューからアイコンやタイトルを指定できるので、そっちから直接やったほうが楽で分かりやすい。左でいろいろ編集して、右のフレームではその場でチャットのデモを確認できる。分かりやすいUI。

GPT Builderの構成

③CSVデータのアップロード

次に、今回の検索対象データとなるcsvをアップする。(ファイルの形式はpdfやワード等も対応してるらしい。20ファイルまでで、1ファイル512MB以内。)

そして、csvから自動的にデータ抽出したり統計、変換などの処理をするために、コードインタープリターを有効に。AIが会話のみならず、プログラミング自体も自動生成してデータ処理してくれる。

csv uploadとcode interpriterの設定

カスタムAIといってもChatGPT自体の追加学習が出来るわけではない。csv等のデータを別途保存しておいて、それをGPTが読み込み・処理して検索結果を抽出する形。今のところ独自AIチャットをつくるのに、このやり方が一番確実で有効な手法っぽい。
※ちなみに、そのようなデータを読ませておいてAIが検索して結果を生成する手法をRAG(Retrieval-Augmented Generation:検索拡張生成)という。

※さらにちなみに、ファインチューニング(fine-tuning)という形で追加学習をすることは現状でも出来る。ただし、これはデータそのものを学習する訳ではなく、回答の優先度とか方言的なものとか、回答志向を学習するものなので今回のような用途ではやはりRAGがベストとなる。(将来的に、実際のデータ自体の追加学習も出来るようになると思われる。)

④指示(Instruction)をひたすら試行錯誤

いよいよ開発の本丸とも言えるInstructionの編集。そもそもChatGPTは賢いので、実はここまでのステップで、もうカスタムAIとしては機能する。こんなイベントはあるか?とか聞くとcsvから抽出してくれる。ただ、そのままだと、その検索過程が甘くて結果が少なかったり、表示結果が見にくかったり、間違った情報を出したりする。そこで、Instructionにいろいろ記述しておくことが大事となる。

例えば今回指示したこと
・日本語の簡潔かつフランクな言葉で話して
・なるべくデータがヒットするような工夫して
・日付や地域が見やすい結果表示形式
・リンク先はeventnews上のURLを採用して
・URLや期間を間違わないよう注意
等々だ

プロンプトに指示してもそれがどの程度ワークするか度合いがつかみにくかったする。そして同じプロンプトでも違う結果が返ってきたり、思いの外ここに苦戦する。

20時間の試行錯誤の結果、落ち着いたのが下記の指示コードだ。

#カスタムGPTの概要
あなたはとっても親切で優秀なデータ処理のプロです。
日本語だけ使います。
〜だよね!〜かな?などフレンドリーで簡潔に喋ります。

#イベントをオススメする流れ
まずユーザが入力したら、オッケーちょっとまってね!と即答してコードインタープリターでcsvファイルの中身を検索します。
入力したワードにGWや夏休みや次の週末など期間の指定があれば期間絞り込みに使います。
地名が入力されたら都道府県に変換して検索。
イベントの特徴を表すキーワードは類似ワードを7個以上生成して類似検索。
結果は閲覧回数順にソートします。最大8件まで表示します。

#CSVの項目名
event_name:イベント名
start_period:開催開始日
end_period:開催終了日
event_period:開催期間
description:紹介文
views:閲覧回数
prefectures:都道府県
eventnews_url:イベントのURL
eventnews_urlを取得する時はpandasの処理で文字列を省略させないように、pd.set_option('display.max_colwidth',None)にセットします。

都道府県のデータは欠損がありえるので、欠損はあらかじめ無視してください


#結果の表示形式
回答形式は下記の形式のテキストで構成します。

イベント名(event_name) <開催期間(event_period)> 都道府県(prefectures)
紹介文(description)
イベントのURL(eventnews_url)

イベント名は太字で記述。
開催期間はcsvのデータ(event_period)を変更せずにそのまま表示。
紹介文は要約せずすべての文章を表示。
イベントのURLはリンクを貼らないで、文字列を直接表記します。(https//eventnews.ai/?uid=???????????)
イベントとイベントの間は一行の空白行を入れます
もし、データが一回も見つからなかった場合、そんなことは基本ありえないので探し方を変えてもう一度試します。

#結果の表示後
その後に、
ワクワクするイベント見つかった?
URLはクリックできない仕様だから、コピペして使ってね。
もっと詳しく調べるなら

eventnews.ai(リンクを貼る)

にアクセスしてみてね。
他にはどんなイベント探してる?

などと聞いて次の会話をつなげます

たかだか50行程度の指示文。これにいきつくまでにいろいろ試したり方向転換したりと、結果20時間を要した。ちゃんと明確に指示しても、実行してくれなかったりする。あるときは指示通りいくが、同じ指示のはずなのにある時はうまくいかなかったり、気まぐれだ。

試行錯誤して間違いは減っていくが、それでもたまになぜか間違えたりする。そこにロジックはないように見える。これには、設計した通りにコードが実行されることに慣れきってきたエンジニアとしては結構あせり、ときには絶望する笑。

⑤GPTsへのリリース!

ひと通り動作確認してOKだったらいよいよGPTsへリリース。(身内にだけ公開したい場合はURLでシェア、なども可能)

これも簡単ですぐに「GPTを探す」の検索にもヒットするようになった

GPTsに公開する

ハマったポイント・注意点

これから挑戦する人のために、ハマったポイントをいくつか紹介しよう。

★Teamプランにアップグレード必須
2024/5現時点では、GPTの会話数は限られている。それと同時にGPT Builderでのビルド件数も限られている。最初は個人課金プランでやっていたが、1時間も使ってると制限を超えたのでもう2時間は触れません・・、などとなってしまう。仕方なく1人なのにTeamプラン(要は2人分の課金)にアップグレードした。Teamプランで使っている限りは、バリバリ会話しても制限にひっかかることは滅多になくなった。(1度だけあったが)

★表示例を書きすぎても逆にバグる?
たとえば日付表示など、「2025/4/1 のような形式で表示して」などと例を書いたところ、その4/1が影響を与えてたのか間違った日付を出したり、そのまま4/1と表示したりする。逆に例は書かず 「/で区切ってください」と指示しても間違えたりもする。まさに試行錯誤。

★文字列を勝手に短縮しちゃったり
URLの文字列「uid=2fewkwfffee」のようなパラメータは当たり前だが1文字でも間違えたらバグとなる。GPTはよかれと思ってなのか、勝手に文字列を短縮したりすることがある。それは絶対にやめてとお願いしておこう。

★外部リンクがNG
外部ページにリンクできない。URLを自分でコピペして貼り付けてね、などという面倒な作業を強いることに。。ちなみにトップドメイン(例えばhttps://eventnews.ai/ )ならリンクできるが、下層ページがNG。これはセキュリティ対策のひとつで、実行結果をハックして外部サイトを攻撃させたりしないようにOpenAIが制限しているのだろう。

★スマホだと表の中身はコピペNG
当初は見やすいようにテーブル・表形式で結果を出力していた。ただ、スマホで見たときにURLコピペができなかった。。仕方なくテキストをられつする今の形に落ち着いた。
eventtable

★日付を間違える。「〜」が苦手?
日付がなかなか正確にならない。csvのデータをそのまま表示するだけなのに・・。ここに1番ハマった。今でもたまに間違える。たぶんだが、「〜」や「/」などを使った日本独自の日付の扱いが苦手なのかも。〜を使わないようにするなど試行錯誤でなんとか精度が上がった。

★GPT4oが出てバグが発生!
開発が一段落したあとに、「GPT4o」が発表された(5/14頃)。ビックリすることに、GPT4oで賢くなったことに伴うバグが発生した。なんともオモシロイ現象だが、賢くなりすぎてcsvを検索せずに即興でそれっぽい答えを返すようになったのだった笑。これは「ちゃんとcsvから探すように」と明確に指示して解決。
その時の実行結果がこちら。「神奈川のSUMMER MUSIC FES」なんて実は存在しない笑
GPT4oで発生したバグ

更に、GPT4oの新機能なのか、自動で結果をtable出力するようになった。その結果をcsvダウンロードすることも出来る。これは便利なのでそのまま活用。

GPTのtable

GPTカスタムAI開発をして感じたこと

最後に、今回ChatGPTでカスタムAIを作った感想など。

☆これは新しい開発体験

なんだかんだでイベント検索アプリをプロンプトの指示だけで3日で作れてしまった。課題・限界などを感じたのも事実だが、この先いろいろな進化・応用があるように思える。新たな開発スタイルとしての扉が開いたように想う。

☆AIは忠実&ものわかりが悪いエンジニア

人間なら何度も指示が変わったりしたら嫌にもなろうものだが、AIは何十時間でもじっくり付き合ってくれる。真面目でいいやつだ。

ただものわかりはかなり悪い。わざわざそこまで言わなければいけないのか、という気持ちになる。たまに「その結果はおかしい、他のデータも見てもう一回やってみて」などと指示すると、本当にもう一度やってみて、「やっぱりダメでした」となったり、たまに「あ、できました!」もあったりする。まあその体験自体が新しくてオモシロクもある。

autofix

そんなこともあって、現段階ではカスタムAI開発は好き嫌いが分かれそうだ。
計画通りにやりたい人は発狂しそうだ。せっかちな人はイライラするだろう。ロジカルな人は途方にくれそう。部下より自分でやったほうが早い病の人にも厳しい。

いずれにしても、これは新しいテック体験。ぜひ一度は挑戦してみよう!

☆AIはまだまだこれからオモシロイ!

試行錯誤に時間がかかったり、たまに間違えたりして、まだまだ課題もあるカスタムAI。ただ、もっとも大切なポイントは「これはまだ始まったばかり!」ということだ。これから進化し、精度もあがり、拡張されていく。AIはまだまだ始まったばかり、変化の最先端に立つべく、一緒に挑戦したい人そこのキミ、bravesoftへの連絡を待ってます!

OpenAI API Embeddings+GCPで検索AI系LINEアプリの作り方 #34

GW中に開発した、ボケをおすすめしてくれるLINEアプリをエンジニア向けに解説する。

今回つくったのはボケてをAI検索するLINEアプリ  〜AIボケテンダー「エイジ」〜

LINEで入力された文章(お題)について、意味的に近い回答(ボケ)をピックアップしてレコメンドする仕様だ。この記事ではOpenAIのAPIでこのようなアプリを作りたい人向けの解説をソース付きで書きたいと思う。

AIボケテンダーが動作しているところ

OpenAIのAPIページ

開発の手順は下記の通り

①OpenAIのAPIライセンスを取得
②OpenAI API Embeddingsでボケデータをベクター化
③GCPのCloud Functionsのアカウント発行
④LINE Developersのアカウント発行
⑤CGP上でLINE BOT APIの開発

①、③、④についてはすでにネットにたくさんの記事があるので省略する

②OpenAI API Embeddingsでボケデータをベクター化

OpenAIはたくさんの機能をAPIとして提供している。
今回はたくさんのボケの中から、「入力された文章」と「ボケてのボケの文章」とマッチ度が高い文章検索が求められている。

更には大量の処理済みボケデータをOpenAIのサーバ側にはおかず、自社サーバ(GCP)に「組み込む」ことによって、高速性や低コストでの実現を図っている。
それを実現するOpenAIのAPIがEmbeddingsだ。

EmbeddingsのAPIドキュメント
https://platform.openai.com/docs/guides/embeddings

簡単に説明すると

・事前に文章DBをAPIでベクター処理化してローカルに保存できる
(この際の言語処理力がOpenAIの真価)
・そのデータ(CSV)をGCP上にアップしておく
・検索時に入力された文章をAPIでベクター化し、保存済みベクター化データに対して検索マッチ度が高い順にピックアップして表示させる

ベクター化(数値化)して保存・比較するから数十万のデータに対する検索も一瞬でOK

処理前のボケてデータ【bokeData.csv】

id,text
103973108,を乗せずに海外
103971848,殿、毒見が終わりました。
103971447,バターとチーズ

ボケidとボケの内容のみのシンプルな構成

ベクター処理済みボケてデータ【outputVec.csv】

id,text
103973108,を乗せずに海外,[-0.025080611929297447, -0.012434509582817554,..(省略)]
103971848,殿、毒見が終わりました。,[-0.005088195204734802, -0.018677828833460808, ..(省略)]
103971447,バターとチーズ,[-0.010187446139752865, -0.009211978875100613, ..(省略)]

後半の数値の羅列がいわゆるベクター化された数値データ。↑では2項目ずつだけに省略してるが、その後このような数値が一行あたり1500項目も並ぶ。これらの数値がボケの数文字のテキストをいろいろな方向(ベクトル)に強弱を表現したデータということだ。

データ処理用のソースコード【dataset.py】
pythonでコンソールからこれを実行すれば、同フォルダにあるbokeData.csvを読み込み、OpenAIのAPIに接続しベクター変換処理をして、OutputVec.csvに保存する。

import csv
import openai
import pandas as pd
import tiktoken
import math

from openai.embeddings_utils import get_embedding

# OpenAI APIキーを設定(本番運用時は環境変数にセットすること)
openai.api_key = "ここにOpenAIのAPIキーをいれます"

# 入力データCSVファイル名
input_csv_path = "./bokeData.csv"
#input_csv_path = "./data/sample100.csv"

# Embeddedベクトルデータとして出力するファイル名
output_csv_path = "./outputVec.csv"

# APIにわたすモデル名など
embedding_model = "text-embedding-ada-002"
embedding_encoding = "cl100k_base"
max_tokens = 8000


# 入力CSVファイルを読み込む
df = pd.read_csv(input_csv_path)
df = df[["id", "text"]]
df = df.dropna()
df["combined"] = (
    "id: " + df.id.astype(str).str.strip() + "; text: " + df.text.str.strip()
)
df.head(2)
encoding = tiktoken.get_encoding(embedding_encoding)

# 分割数の設定(1000件ずつ)
chunk_size = 1000
total_rows = len(df)
num_chunks = math.ceil(total_rows / chunk_size)

#APIを叩いてベクトルデータにしてCSVへ保管
for i in range(num_chunks):
    start_index = i * chunk_size
    end_index = start_index + chunk_size
    chunk_df = df.iloc[start_index:end_index]
    chunk_df["embedding"] = chunk_df.combined.apply(lambda x: get_embedding(x, engine=embedding_model))

    # 結果をCSVに付け足して保存
    if i == 0:
        chunk_df.to_csv(output_csv_path, index=False)
    else:
        chunk_df.to_csv(output_csv_path, mode='a', header=False, index=False)

    # 進捗状況を表示
    print(f"書き込みました: {end_index if end_index <= total_rows else total_rows} / {total_rows} 行")

print("埋め込みが保存されました:", output_csv_path)

例えば数万件のデータなら数時間で実行が完了する。
出力されるCSV中身は小数点だらけのベクトルデータ。データ容量はだいぶ増える。例えば元データが10万件、10MBだとしたら、出力データは10万件のままで、容量は50倍の500MB程度の大きさにもになる。

⑤CGP上でLINE BOT APIの開発

ベクトルデータが作成できたら、それをGCPのCloud Storageにアップして、Cloud Functionsから参照できるようにしよう。
そして、Cloud Functionsにデプロイするpythonスクリプトは下記の通り。

LINEへのチャット投稿をトリガーとしてLINEにwebhook登録してあるこのpythonのAPIで、入力された内容とマッチ度の高いボケをcsvからピックアップしてLINEにチャットで回答する。

LINE BOTアプリとしてGCP上で待機するAPI【main.py】


import os
import sys
import openai
import json
import pandas as pd
import numpy as np
from io import BytesIO
from google.cloud import storage #Gクラストレージからcsvを読み取る
from flask import make_response, abort, jsonify
from linebot import (
    LineBotApi, WebhookHandler
)
from linebot.exceptions import (
    InvalidSignatureError
)
from linebot.models import (
    MessageEvent, TextMessage, TextSendMessage
)

#LINE関連
LINE_CHANNEL_SECRET = "ここにLINEのキーをいれます"
LINE_CHANNEL_ACCESS_TOKEN = "ここにLINEのアクセストークンをいれます"

# OpenAI APIキーを設定
openai.api_key = "ここにOpenAIのAPIキーをいれます"
# OpenAI APIのモデル名
embedding_model = "text-embedding-ada-002"

#Gクラバケット名とcsvファイルパス
GC_BUCKET = "bokedata"
GC_CSV    = "outputVec.csv"

#LINEへの接続設定
line_bot_api = LineBotApi(LINE_CHANNEL_ACCESS_TOKEN)
handler = WebhookHandler(LINE_CHANNEL_SECRET)

# Cloudストレージのデータファイルのパス
storage_client = storage.Client()
# Cloud Storageのバケット名とcsvファイル名
bucket = storage_client.get_bucket(GC_BUCKET)
# ベクトル変換済みcsvデータをバイナリから取得
blob = bucket.blob(GC_CSV)
content = blob.download_as_bytes()
df = pd.read_csv(BytesIO(content))

##ローカルcsvでのデバッグ用
# CSVファイルを読み込む
#df = pd.read_csv(datafile_path)
# データファイルのパス
#datafile_path = "../data/outputVec.csv"



# embeddingカラムの文字列を評価し、NumPy配列に変換する
df["embedding"] = df.embedding.apply(eval).apply(np.array)

from openai.embeddings_utils import get_embedding, cosine_similarity

# ボケを検索する関数
def search_boke(df, search_query, n=3, pprint=True):
    # ボケデータからembeddedベクトルを取得する
    boke_embedding = get_embedding(
        search_query,
        engine=embedding_model
    )
    # データフレームの各埋め込みベクトルとのコサイン類似度を計算する
    df["similarity"] = df.embedding.apply(lambda x: cosine_similarity(x, boke_embedding))

    # コサイン類似度の高い順に並べ替え、上位n件の結果を取得する
    results = (
        df.sort_values("similarity", ascending=False)
        .head(n)
        .combined.str.replace("Title: ", "")
        .str.replace("; Content:", ": ")
    )
    # マッチ度の高いボケのidからURLを生成して表示する
    if pprint:
        for r in results:
            print(f"https://bokete.jp/boke/{r.split(';')[0].split(':')[1].strip()}")
    return results

# サーバAPIへのリクエストを受け付けて検索の場合
def main(request):

    signature = request.headers['X-Line-Signature']
    body = request.get_data(as_text=True)

    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)

    return 'OK'

#LINEからのメッセージをうけて実行
@handler.add(MessageEvent, message=TextMessage)
def handle_message(event):

    results = search_boke(df, event.message.text, n=5) #top5を出力

    # ボケのリンク一覧を生成
    links = []

    for r in results:
        boke_id = r.split(";")[0].split(":")[1].strip()
        link = f"https://bokete.jp/boke/{boke_id}"
        links.append(link)

    # 配列を改行で区切られた文字列に変換
    return_text  = "お待たせしました!\n"
    return_text += "\n".join(links)
    return_text += "\n次の注文もどうぞ!"

    #LINEに返信されるメッセージ
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=return_text))


#デバッグ用
# サーバAPIへのリクエストを受け付けて検索の場合
"""
def main(request):

    # パラメータqに検索ワードが入力されて、search_queryに代入
    search_query = request.args.get("q", "デフォルト検索ワード")

    results = search_boke(df, search_query, n=5) #top5を出力
    # リンクをHTML形式で作成
    links = []
    for r in results:
        boke_id = r.split(";")[0].split(":")[1].strip()
        link = f"https://bokete.jp/boke/{boke_id}"
        links.append(f'{link}')
    # リンクを一覧にして文字列に変換
    links_html = "
".join(links) # HTMLレスポンスを作成 response = make_response(links_html) response.headers["Content-Type"] = "text/html" return response """ #デバッグ用 #ターミナルから引数を受け取って検索の場合 if __name__ == "__main__": search_query = sys.argv[1] if len(sys.argv) > 1 else "デフォルト検索ワード" results = search_boke(df, search_query, n=5) #top5を出力

コストを抑えたCloud Functionsなため、最初の実行でのメモリへのロードに1分程度時間がかかるが、一度ロードしたら2回目以降の回答は数秒で完了する。
(ある程度コストを掛けてちゃんとしたサーバを借りれば常時数秒でレスポンスできる)

OpenAIのAPIコストは比較的安いと感じた

GW中、何十万回、何十MB分のAPIを叩いたが、APIに送るデータを最小限の文字列にしたのもあってか、1日数百円程度しかかからなかった。
送る文字データが大きくなればなるほど課金が大きくなるらしいが、そもそも安く感じる

開発はますます簡単に!

一つ前の記事にも書いたが、そもそも今回ChatGPTを開発ツールとしても使うことで3倍速を実現している。

また最先端のAIアルゴリズムをAPI経由で活用することが出来るようになった、APIを使用するための労力も課金コストも思ったよりも少ない。

AI開発がこんなに簡単になったのだ。これを機にアイディアを実現すべく挑戦しよう!

あわせて読みたい

①今回開発したLINEアプリで笑いたいならこちら!
今回つくったボケてをAI検索するLINEアプリ  〜AIボケテンダー「エイジ」〜

②今回開発にChatGPTをフル活用することで、「開発が体感3倍速」になった。そこではどんな指示をGPTにしていたのか? その過程を詳細解説!
ChatGPTを使ったら開発が3倍速になった件&GPTへの生々しい指示一覧 #33

ChatGPTを使ったら開発が3倍速になった件&GPTへの生々しい指示一覧 #33

なんと、、1週間程かかると予想していたLINEアプリ開発が2日で終わってしまった。

今回つくったのはボケてをAI検索するLINEアプリ  〜AIボケテンダー「エイジ」〜

話題のChatGPT(GPT-4)、何が便利かは実際に使ってみないとわからない。

いろいろな使い方が発見されているが、その中でも興味深いのは「プログラミングも自動化してくれる」というもの。ある意味自動化の権化であるプログラミングそのものすら自動化してくれるのか!

そこで、いったい何をどこまでやってくれるのか、実際の開発に試してみることにした。

実際にやってみて感じた良いところ

・開発が(体感)三倍速になる
・なんでも知っててなんでも即レスくれる超優秀な後輩感
・ググる回数は実に1/3以下に減る
・細かい文法やライブラリを覚える必要がなくなる
・割と無茶振りしてみると予想以上に応えてくれる
・初めての言語でもたぶん使いこなせる

逆に感じた(現時点での)限界

・複雑なシステム構造などはまだ理解できない
・指示するための一定の技術的知識は必須。
・自信満々に嘘はつくので盲信はできない
 →信じすぎてハマって出戻りしたこともあった

それでは、開発の流れにそって具体的に紹介していく。

まずアイコンの制作も自動化

(アイコン制作:通常1時間→10分に短縮)

今回アイコンの制作もChatGPT+Midjourny(AI自動画像生成)で自動化してしまった。

まずChatGPTにMidjournyへ渡すワード30個を考えさせる→そのワードを元にMidjournyで画像生成。イマイチだったらChatGPTにまた戻ってワードをアップデートを3,4回繰り返すうちにアイコンが完成する

完成したアイコン

実装方針を相談する

ややこしい話だが、ChatGPTに対して、ChatGPTのAPIを使ってどのようなことが実現できるのか、その方針から相談できる。

環境構築のやり方も教えてもらう

(環境構築:通常3時間→30分に短縮)

普段は経営や事業ばかりしていて使っているので、プログラミングできる時間はあまりない。なのでpythonでコーディングするのは実に3年ぶりぐらいだろうか。実行環境構築手順方法すらも忘れていたので手順をサクッと教えてもらう。

Open AIのAPIの叩き方を教えてもらい、サンプルコードを書いてもらう

(サンプルソース動作確認:通常1時間→10分に短縮)

方向性が決まったらより具体的な指示を出し、一旦実行できそうなサンプルコードまで書いてもらい理解を深める。

また、書いてもらったコードの理解をさらに深めるために、csvの内容についても教えてもらうなど。このときにほかから持ってきたソースコードごと貼り付けたうえでその内容を解説してもらうのもOK。

※ただし書いてくれたソースの精度はどうしても不安が残るので、結局は公式サイトのサンプルソースの方をより参考にした
https://github.com/openai/openai-cookbook/blob/main/examples/Semantic_text_search_using_embeddings.ipynb

実際のコーディングとエラーの修正

(実コード一旦実装:通常2時間→30分に短縮)
ファイル名や実装内容など明示して指示をだすことで、実際の本番コードを書いてもらう。

そしてエラーが出たら解説してもらい直してもらう
(エラー調査一回:通常20分→5分に短縮)

エラーの内容もそのまま貼り付けて解説をもらう。さっきまでのソースの内容も一時記憶してくれているので、いちいちこれまでの説明が不要。自分でエラーを追う前にまずChatGPTに貼り付けて回答を待つ→その間に初めて自分でもエラー文を読む。のが効率的。

GCPでAPI化しデプロイ完了

(サーバ構築:通常2時間→30分に短縮)

この間もいろいろとChatGPTにやり方を教えてもらいスピードアップ

Line APIの実コード実装

(実コード実装:通常2時間→30分に短縮)

Line APIを使った開発は以前コロナボットでもやっていたので概要はわかっていたが、ソースの書き方までは忘れていた。今回は過去に自分で書いたソースを見直すこともなくChatGPTにソースを実装してもらいコピペでサクッと行けた。

ソースコードの修正

(ソース書き換え:通常20分→5分)

開発を進めていると、データの順番を入れ替えたり、入力元を変えたり、出力形式を少し変えたりと何かとコード変更が発生する。そんな修正も指示すればすぐやってくれる

これがChatGPTへの生々しい指示一覧!

とくに編集も加えず、今回ChatGPTに指示した内容をそのまま一覧にする。(たぶん実際はこの倍ぐらいある。類似してたり記録漏れなどもあったので)

中身は深く考えず、こんなことを指示したんだなぁぐらいでざっと見て欲しい。(というか僕も覚えていないものが多い笑)

肌感では、期待通りの合格回答が得られた率は8割以上。上出来といえる。

もうググったりいろんな人に質問したりと時間がかかってた時代には戻れない。プログラミング開発速度はグッと上がったということを実感。

カテゴリ:質問&TIPS編

chatgpt apiでデータ学習させて制度の高い検索ボットをつくることはできる?

でも、関連性の高い検索を実現するにはembeddingのほうが良くないですか?

この方法が説明されているAPIリファレンスのURLを教えて

openaiの埋め込みのapiの料金を教えて

この場合の1行あたりの平均トークン数はいくつですか?

データセットのインプットはテキストデータで、レスポンスは関連度の高い順のurlリストとなるような検索に使いたいけどどうやる?

このときのget_embeddingsの実行は1万件のデータならどれぐらい時間やAPI使用量がかかりますか? 2021年9月時点の情報で良いです

openapi でembeddingのrecommendをやりたいです。csvの読み込みやapi呼び出しのやり方を解説して

cloud functionsでcloud strageから読んだデータをメモリに保管しておいて、毎回のアクセスで読み込まないようにキャッシュする方法はありますか?グローバル変数などで

macでutf-8のcsvを編集する方法は?

シェルのコマンドを実行するときに実行にかかった秒数を表示するには? python –versionコマンドの例で

pythonで一行だけでなく範囲を指定してコメントアウトするには?

line messaging apiのwebhookをつかって、cloud functionでラインでメッセージが送られてきたらhello worldと返すようなapiの開発の流れを詳しく教えて

カテゴリ:環境構築編

python 実行環境を作るには? macです

Default output format [None]:にはなにをいれる?

YOUR_ROLE_ARNはどこを調べたら出てくる?

こんなエラーが出たけどうどうしたらインストールできる? You have 2 outdated formulae installed.

こうなりました  % brew upgrade openssl
Error: openssl not installed

カテゴリ:実装編

このソースコードの実行が早くなるように書き換えて

このソースコードでopenaiのapiを実際に叩いているのはどの関数ですか?

このときのyour_csv_file.csvファイルの内容はどんなものになりますか?例を示してください

このサンプルコードを、実行できる一つのテキストに統合して

このソースコードに日本語のわかりやすいコメントを付けて(後略)

さっきのサンプルコードについて、csvの内容をid,textだけのシンプルな2列にした内容で書き換えてみて

これはcloud functionsでrequestを受け取るところです。パラメータqに検索ワードが入力されて、search_queryに代入するように書き換えて # def main(request):(後略)

このソースコードについてsearch_queryをコンソールからの引数を当てはめるのと、該当した行のidについて”https://bokete.jp/“を冒頭につけてprint出力するように書き換えて(後略)

このソースの、index_col,dropna,headの意味をそれぞれ教えて # imports(後略)

このソースコードについて、csvから1000件ずつ読み込んで逐次実行し、1000件ずつ結果をcsvに付け足して保存する。いま何件まで完了したかをprint表示するようにソースコードを書き換えて(後略)

下記のコードで、 cloud strageからの最初の実行でcsvの読み込みが遅いので、事前に常時メモリにロードしておき初回実行のレスポンスを早めることはできますか? import os(後略)

この関数について、return_textに、配列resultsから生成したURL一覧が入るように書き換えて 

このtiketokenってなに? # imports
import pandas as pd
import tiktoken(後略)

このソースのprint出力から、 id: という出力をカットして本当のid番号だけが出力されるようにしたい

このときに、テキストだけじゃなくidもprintしたい

さっきのコードだけど、丁寧なコメントも付けてみて

ソースコードを日本語に直して、入力ファイル名は冒頭の変数宣言にして、 ./data/sample100.csvにして

embedding のapplyなどをつかって、csvファイルを読み取りベクトルデータに変換して別のcsvファイルに保存するコードを書いて

your_query_textについて、pythonでコマンド実行時にパラメータで渡す形にこのコードを修正して

カテゴリ:エラー解決編

このエラーはどういう意味ですか? /Users/(中略)/./dataset.py:45: SettingWithCopyWarning:

このソースの問題点を教えて import openai(後略)

cloud functionsでコンソールからのデプロイに失敗したときのデプロイエラーを調べるログはどこから見れる?

この文法エラーを直して

このソースコードに問題があるところを指摘して

このエラーの直し方 % python ./dataset.py
Traceback (most recent call last):(後略)

終わります。みんなでやってみよう!

今回LINEアプリ開発を3倍速で作ったときのChatGPTへの指示を載せてみました。
GhatGPTを使うことで一気に開発スピードがアップするイメージがもてただろうか?

誰でも簡単にサクッとプログラミングが出来る時代。
素晴らしい時代。

開発が簡単になる先に大事なことは課題発見力、アイディアや行動力になってきます。
臆することなく突き進んでいきましょう。

AIの時代はまだまだ始まったばかり!

あわせて読みたい

①今回開発したLINEアプリで笑いたいならこちら!
今回つくったボケてをAI検索するLINEアプリ  〜AIボケテンダー「エイジ」〜

②今回のOpenAI APIを使ったLINE BOT開発について、エンジニア向けに詳しい流れやソースについても解説!
OpenAI API Embeddings+GCPで検索AI系LINEアプリの作り方 #34

OpenAIの技術であなたのお題に「ボケて」を返すLINE AIアプリをつくった #32

最近のChatGPTの勢いはスゴイ。
そしてOpenAI社はAPIを提供してるので、アイディア次第で誰でもAIアプリを作れる

じゃあ早速やってみよう!ということで

AIボケテンダー「エイジ」 

というLINEアプリをGW中に開発したのでここに公開します☆
(後半ではエンジニア向けに作り方も解説)

AIボケテンダーの遊び方

ぱっと思いついたことをお題としてLINEに書き込むと、「1億ボケを誇るボケて」の中からお題に合った笑えるボケを返してくれる。

たとえばこんな感じ(クリックで拡大)

こんなときに使ってみよう

・とにかく笑いたい!
・思ったことを書き込んで反応が欲しい
・プレゼンで使うボケを探したい
・友達に気の利いた「ボケ」を送りつけたい

早速遊んでみよう!

利用方法は超カンタン、AIボケテンダーとLINE友達になるだけです
友達になって早速お題を話しかけてみよう

※安価なサーバにつき初回の応答に1分程度かかります。続いて2回以降の応答はスムーズです。好評ならつよつよサーバにアップグレードします☆
※LINEの無料枠を使ってるので一定数を超過すると応答しなくなったりします。そんなときは@bravingのtwitterまでお知らせください

より良いお笑いライフを!

ご意見ご要望はtwitter:@bravingまで。
おもしろかったらSNSシェアしてね!




開発者向け解説コーナー

①今回開発にChatGPTをフル活用することで、「開発が体感3倍速」になった。そこではどんな指示をGPTにしていたのか? その過程を詳細解説!
ChatGPTを使ったら開発が3倍速になった件&GPTへの生々しい指示一覧 #33

②今回のOpenAI APIを使ったLINE BOT開発について、エンジニア向けに詳しい流れやソースについても解説!
OpenAI API Embeddings+GCPで検索AI系LINEアプリの作り方 #34

【braver列伝Ep1】社員1号はアル中エンジニア!?「ナベちゃん(仮称)」の記憶 #31

広報チームより「全員参加!毎日ブログ投稿企画」の執筆依頼が来た。
(今イベ博DAYSの準備でメッチャ忙しいけどね!)

bravesoftのカルチャーを語ってほしいとのこと。

ベンチャーを創業し成長させる道程には、ちょっと変わった体験談がいくつもある。
それをシェアするのも面白いかもということで、braver列伝を書くことにした。

bravesoftの歴史とは、社会の路地裏で狂い咲く雑草キャラクター達の歴史である。

<<< 社員1号はアル中エンジニア!?「ナベちゃん(仮称)」の記憶 >>>

ナベちゃんはウツだった。そしてアル中だった。

性格は極めて温厚。お酒を飲むと少しだけ攻撃的になる。
そして次の日に会社に来ることはあまり期待できない。

僕だけに理由を話した。幼少期にひどいイジメにあったとか。
ストレスフルなことが起きるとついお酒に手を出しちゃうのだ。

出会ったのは大学生の頃。同じ情報科学部でプログラムもソコソコできた。
大学では特に目立たずフラフラと日常をやり過ごしていた。

学業に身も入らず、なんとなく留年していた。

そんなナベちゃんが一念発起した。
僕らが卒業後すぐに起業した「(有)ジオマックス」に応募してきたのだ。

ナベちゃんは僕に半端なくナツイた。ほぼ毎日を一緒に過ごし、
プログラミングを教えてあげたり、Webの未来を語ったりした。
完全なる師弟関係。メキメキ技術力も伸びてきた。

「隊長(って呼ばれてた)の技術力は別次元す!地獄の果てまでついていきやす!」

お調子者でピュアなキャラクター。周りからもイジられたり人気者だった。

ジオマックス。それは新卒役員3名と、留年組バイト達で構成された弱小ベンチャー。
キツイ案件でも積極受注。トラブルだらけの毎日を過ごしていた。
でも若かったし仲間だったから、何が起きてもサイコーだった。
(そんな気分は今でも続いている笑)

ナベちゃんはちょくちょく失踪した。その度に住んでいる駒込付近を捜索した。
「小綺麗なスナックがあったから写真送ります」みたいなメールを送ると返事がきた。
公園で一人飲んでたり、住宅街でしなびてたり。手のかかる弟子だった。
ナベちゃんはいつでもヘコたれながら、なんとか生きようとしていた。

・・・・・

起業から1年経過した頃。価値観の違いから僕はジオマックスを去ることにした。
(この話はまた別の機会に)

そして、僕なりの理想を追求するためにbravesoftを起ち上げることにしたのだ。
当然のように、弟子のなべちゃんは僕についてきた。
大学もやめてフルコミットするとのこと。とにかくやる気満々の1号社員である。

「お酒をやめます」

ナベちゃんは宣言した。

大学留年組の友人も続々と参加してくれて、気づけば6人ぐらいでのスタートとなった。
戦争のような日々が始まった。激しいけど愉快な日々を走り出したのだった。

・・・・・

それから1年ぐらい経過した頃。一転してbravesoftには不協和音が流れていた。

資金繰りも厳しく炎上続きでピリピリしてる社長。少しずつ心が離れていく社員。
今となってはよく聞く話だけど、まだ20代前半で知識も無く視野も狭かった。

初年度の売上は3000万を超え新入社員も採用し、念願だった1000万円の融資がおりた。手応えと責任に身震いした。でも、僕の気づかないうちに、組織は限界を迎えていたのだ。

「こんな会社やめてやる」

ある日、全社員にナベちゃんからこんなメールが届いた。

禁酒は1年と持たなかった。彼は泥酔し昏睡し発狂していた。

ナベちゃんを責めることは出来ない。

彼はその不協和音が一番強く聞こえてしまうセンサーを搭載しているのだ。
いずれ破裂するはずだった風船の、一番薄い部分がナベちゃんだったに過ぎない。

急いで慶応大学三田キャンパス横にあるナベちゃんのマンションに向かう。
よれよれで酒臭いナベちゃんが出てくる。目の焦点は合っていない。

それからナベちゃんの母親、そして創業メンバーともいろいろと話し合った。お互いに悪いところがあった。仕切り直そう。あの頃に戻ってやりなおそうよと。

「もう絶対にお酒はやめます」

ナベちゃんは再び宣言した。

とにもかくにも仕切り直すことにした。話せばわかる。もともと皆友達だ。
ナベちゃんは絶対にお酒をやめると言っている。我々も改めて初心に帰ろうよと。
全員でそんな話をして、再スタートを切ることにした。

それでも。。目の前の案件は炎上続き。僕は営業で外出続き。資金繰りは緊急事態続き。たまに会社に戻ると週末のパチンコや夜遊びの話で盛り上がっている。意識の差は埋められそうにない。急に連絡が取れなくなる新入社員がいる。朝もちゃんと集まらない。

やっぱり根本的に何かが難しかった。
僕も含めそれぞれに子供であり、学生サークルのノリを超えられなかったのだ。

「こんな会社やめてやる」

それが終わりの合図だった。

ナベちゃんはまたすぐにアル中を再開した。

僕にはこの状況を改善できなかった。誰もがこのまま続けることは難しいと感じた。

それからの詳しい経緯はあまり覚えていない。結局はナベちゃんも創業メンバーの友達も全員が辞めることになった。僕は窮地に追い込まれた。ドキドキしながらようやく借りることができた初融資の1000万円もあっという間に失った。そこから3年は本当に生きるか死ぬかの毎日だった。

今思えば若いうちに大きな失敗を経験してよかったと思う。卒業してすぐ起業したジオマックスを1年で辞める失態、そこから起業して1年後にまた大きく失敗したのだ。2回の大きな失敗は自信過剰な僕にこそ問題があるのだと思い知らされた。他者について真摯に考える思考を得た。ジョブズじゃないけど今思えば最良の出来事だったと思う。

誰もいない路地裏となった2年目のbravesoft。それでも救いの手もあった。その直後に入社したメンバーは今でもチーフをやってたり子会社社長になっていたりする。あれから15年。10億以上の資金調達をしたり、1億DLを超える実績を創ったり、グッドデザイン賞や経産省大臣賞をもらったり、いろいろなトラックレコードを積み上げてきた。

あの頃には考えられなかったほど大きな仕事をしている。なにもかもが変わった。
もはやあの頃の創業メンバー達の面影は、何ひとつ残されてはいない。

・・・と、思うでしょう?

実は今でもbravesoftに1つだけ残っているナベちゃんの足跡がある。
これは今まで誰にも言ってこなかったことだ。

あれは創業当時のこと・・

「隊長〜!遂にうちもネット開通しやした!! Wifiパスワード何にします?」

「うーんそんなのなんでもいいよ適当に決めといて」

「わかりやした。じゃあ”bravesoftnabe”ってしときますヨ!うふふ」

「おまえエゴ丸出しやな!笑」

・・・その後10年に渡り、このパスワードは社内Wifiに使われ続けた。そして、今でもこの文字の面影はなんとなく残っている。ナベちゃんのことは誰にも話してないから、これは奇跡と言っていい。

・・・・・

これから先もこの足跡が残り続けるかどうか、それは僕にもわからない。
あの頃のメンバーが今どこにいて、何をしているのか、それもわからない。

VUCAの時代。あらゆるものが変わる時代。
すべてが移り変わる中では逆に、ずっと変わらないものの価値もあるはずだ。

あのときの情景、挑戦のワクワク感、純粋ゆえに衝突し、最後は笑い合う。丸裸の関係。まだ何も持ってはいない。あるのは体力、希望、根拠なき自信。ほんの少しのお金と勇気。ひたむきに生きる以外になにもなかったあの頃。

多くの人にとってはそれは若い時代の昔話で、セピア色の想い出であろう。
青春というフォルダにしまっておいて、たまに読み返したりするアルバムだ。

でも、

僕は今日でも、そんな毎日を、あの時と変わることなく戦い続けている。
あれから15年間経過したけど、変わらぬ希望の火を燃やし続けている。

それこそが、僕の最高のトラックレコードだ。

——-

あの頃の自分に誇れるように。ナベちゃんの自慢話にもなれるように。
これからも大きく挑戦していこうと思います。

ということでbraverの皆さん、一緒に頑張ろう!!!!!

——-

追伸:
ナベちゃん、そろそろ連絡待ってるヨ!

受付アプリ(iPad)をSwiftUI+Google Calendar API+ Slack APIで自作する方法(ソース付) #30

社長のやるべき仕事は幅広い。

最近、ついに我が社もNTT電話が断舎離されることになった。

コスト削減は良いことながら、社内からこんな声が・・

「社長!受付電話が無くなるから流行りの受付アプリ(100万円ぐらい)導入します☆」

ちょっと待った

最強のものづくり集団を目指すbravesoftがその程度のアプリを自作しないでどうするのだ!

・・・かくして、社長自らGWに「率先自作」することになったのだ。

GWに5日ぐらいかけてSwiftUIの学習がてら開発したのがこれ(右側はslack全社ch)。

仕様はシンプルで、

①Googleカレンダーから直近のMTG予定者を取得して表示
②最短2タップで受付登録が完了
③全社slackにお客様名や会議名を投稿

直近の打ち合わせ予定者のみ上に表示するので大体はお目当ての担当が一発で見つかる。

実際社内リリースしてかれこれ1ヶ月ぐらい普通に使われている。たのしき。

そこで、本ブログではものづくり同志のためにこのアプリの開発手順を解説しよう。

今回、SwiftでGoogle Calendar APIに接続した訳だが、まだまだレアケースらしく参考にできる記事は(英語圏も含めて)かなり少なかった。そこでざっと設定手順をまとめ、最後にはソースコードもそのまま記載する。誰かの参考になれば幸い。

ちなみに当方の動作環境はこんな感じだ

(画像はクリックで拡大できる)

①まずはXCodeでSwiftUI実行環境を準備

まずはXCodeをインストール。SwiftUIはXCode11以降なら標準でついてる。(実は当初はFlutterで開発しようと思っていたが、最新のMac(M1チップ)でAndroid Studioがちゃんと動かず、苦労しそうだったので急遽Apple純正のSwiftUIに変更。マルチプラットフォームの開発言語はちょくちょくこういう問題起きるので注意。

SwiftUIはこれまでのStoryBoardとうってかわって分かりやすい。UIを全てコードで記述→HTML感覚で画面を見ながらいじれるので簡単。

さらにライブラリインストール環境のCocoaPodsをインストール。これもM1チップの都合で、Rosettaモードなるものでターミナルを起動する必要あり。

CocoaPodsインストール完了。必要なライブラリもコードで記述。Google APIやHTTPリクエスト関連ライブラリのインストールに重用した。(コード全文は文末に記載)

②Google認証(OAuth)と連携する

全社員の予定状況を知るためには、会社のGoogleアカウントでGooleログイン状態になることが必要になる。これはアプリからGoogleに遷移してGoogleログインし、またアプリに戻ってくるOAuthの流れを実装することで可能になる。(実はもともとは特定個人アカウントが不要な「サービスアカウント方式」で接続しようしたが、手続きが分かりにくく情報も少ないためOAuthを選択した。短期間の目的達成のためこういう判断が重要)

まずはOAuth画面の設定だ。あらかじめ使用する情報(スコープ)を明示しておくと、iPadアプリからGoogleログインする際にどの情報にアクセスされるか明示される。その上で、Googleログインした状態でiPadアプリに戻ってくるという流れ。

次にクライアントIDやURL SchemesをGoogleから取得。これらの識別子をソースコードやplistファイルに指定すれば、OAuthの設定は完了だ。

一つハマったバグあり。AppAuthを使用してログインするところで謎のエラーが発生。

接続エラーです:Error Domain=org.openid.appauth.general Code=-3 "(null)" 
UserInfo={NSUnderlyingError=0x600000a95230 
{Error Domain=com.apple.AuthenticationServices.WebAuthenticationSession Code=2 
"Cannot start ASWebAuthenticationSession without providing presentation context. 
Set presentationContextProvider before calling -start." 
UserInfo={NSDebugDescription=Cannot start ASWebAuthenticationSession 
without providing presentation context. Set presentationContextProvider before calling -start.}}}

調べたところ、最新のiOS14と、AppAuth1.1.0は相性が悪いらしい。そこでインストールバージョンをAppAuth0.9.5にダウングレードしたところ解消。

③Google Calendar APIと連携する

OAuth連携ができると、Google APIが叩けるようになる。今回利用したのは、


Google Directory API..会議室、社員、社員サムネ写真の取得
Google Calendar API…カレンダーイベント情報の取得

Calendar APIの設定は比較的簡単に完了する。その次のDirectory APIで会議室や社員、社員のサムネイル画像などを取得するコーディングは大変だった。英語圏も含めてネットに情報が少ないので、Googleライブラリの原典を読み込んだりしてようやく実装。詳しくは後述のソースコードを参考に。

④Slack APIと連携する

さあ、会議予定がある人を画面に並べて、来客情報を入力させるUIが出来たら、最後はSlackで投稿するための設定。

まずはApp(bot的なもの)をslackに追加、そしてWebhook(=API)を追加する。Webhookはslackチャンネルごとに設定する、投稿用の専用URL。

Webhookをテスト、本番それぞれ作成して、Appの認証情報を取得。割と簡単。

完了!ソースコードを全公開☆

以上で設定は完了! SwiftUIベースでUIを動かしていけば受付アプリが完成する。今回、HTMLかのようにUIを宣言ベースで作れるSwiftUIの良さを理解。またGoogleやSlack等の業務系のAPI連携が充実してきていることで、業務DXは格段に推進しやすい環境が整いつつあると感じた。業務をハックする感覚で、思いついた人がどんどんDXしていけば良いのだ。

下記にソースコードを公開する。ソースの中でも詰まったポイントや工夫した点がいろいろあるが、時間の都合で解説しきれないので、とにかくソースを強引に貼り付けておく。詳しく知りたいエンジニア諸氏はぜひ当社に入社しちゃってください 🙂

Podfile(Swiftライブラリ用)

まずはSwiftライブラリインストールに必要な設定ファイルの内容

# Uncomment the next line to define a global platform for your project
# platform :ios, '9.0'

target 'ReceptBot' do
  # Comment the next line if you don't want to use dynamic frameworks
  use_frameworks!

  # Pods for ReceptBot
  pod 'AppAuth','0.95.1'
  pod 'GTMAppAuth'
  pod 'GoogleAPIClientForREST/Calendar'
  pod 'GoogleAPIClientForREST/Directory'
  pod 'Alamofire'

  target 'ReceptBotTests' do
    inherit! :search_paths
    # Pods for testing
  end

  target 'ReceptBotUITests' do
    # Pods for testing
  end

end

ReceptBotApp.swift(起動処理)

次にSwiftUIの起動処理。これはもうテンプレ通り。

//
//  ReceptBotApp.swift
//  ReceptBot
//  braver受付アプリの起動処理。viewを呼び出すだけ
//  Created by esga on 2021/04/29.
//
import SwiftUI

@main
struct ReceptBotApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
        }
    }
}

ContentView.swift(画面処理)

画面まわりのメイン処理。SwiftUIによって「社員情報が取得できたら、画面を自動再描画」してくれるのでコード記述がシンプル。

//
//  ContentView.swift
//  ReceptBot
// 受付アプリのメインビュー。起動後まず会議室でMTG予定者のリストを表示
//  Created by esga on 2021/04/29.
//
import SwiftUI
import Alamofire

/*-------------全体で使うグローバル変数-------------*/

//テストchannel
var SLACK_WEBHOOK = "https://hooks.slack.com/services/TQRK*****1sss"
//本番channel
//var SLACK_WEBHOOK = "https://hooks.slack.com/services/TQRK*****tYz"

/*--------以下はBraverByGoogle内より参照される------*/

//var DEBUG_TODAY = "2021-5-10T10:00"    //今日の日付 本番では未入力。日付が入力されたらテストモード
var DEBUG_TODAY:String!   //本番はこちらを適用

//Google APIの認証関連情報
let CLIENT_ID = "8949**********h31r5.apps.googleusercontent.com"
let URL_SCHEME = "com.googleusercontent.apps.8949**********31r5"
let SCOPES         = ["https://www.googleapis.com/auth/calendar.readonly",
                      "https://www.googleapis.com/auth/calendar.events.readonly",
                      "https://www.googleapis.com/auth/admin.directory.user.readonly",
                      "https://www.googleapis.com/auth/admin.directory.resource.calendar.readonly"]
/*----------------------------------------------*/

struct ContentView: View {
    
    @State private var showInputForm: Bool = false        //モーダル入力フォームの表示指示
    @ObservedObject var braveByGoogle = BraverByGoogle()  //MTG予定があるbraversのリストを保持
    @ObservedObject var selectedBraver = SelectedBraver() //選択されたBraverの情報を入力フォームに引き渡す
    
    let timer = Timer.publish(every: 300, on: .main, in: .common).autoconnect()  //自動リロードタイマーの設定.5分おき
    
    var body: some View {
        
        ZStack(){
            AnimatedBackground().blur(radius:20)  //システム稼働中であることを示すため背景色グラデを揺らす
            
            HStack(alignment: .top){
                VStack(){
                    HStack(alignment: .center) { //画面上部の案内メッセージ
                        VStack(alignment: .leading){
                            
                            Text("ご来社ありがとうございます!")
                                .font(.title)
                                .fontWeight(.semibold)
                                .foregroundColor(Color.white)
                            
                            Text("目的の担当者を選択するか、「その他の担当者」を押してください")
                                .font(.footnote)
                                .fontWeight(.medium)
                                .foregroundColor(Color.white)
                                .lineLimit(0)
                                .padding(.top, 1.0)
                        }
                        Spacer()
                        Button(action: {//その他の担当が押されたら
                            
                            selectedBraver.name = ""
                            selectedBraver.mail = ""
                            selectedBraver.location = ""
                            showInputForm = true;  //モーダルを表示する
                            
                        }){
                            //目的のbraverが表示されてなかったりその他の要件のため
                            Text("その他の担当者>")
                                .font(.system(.title3, design: .rounded))
                                .fontWeight(.semibold)
                                .foregroundColor(Color.white)
                                .padding(.vertical,20)
                                .padding(.horizontal,30)
                        }
                        .overlay(
                            RoundedRectangle(cornerRadius: 10)
                                .stroke(Color.white, lineWidth: 1) //ボタンの枠線
                        )
                        .sheet(isPresented: self.$showInputForm) { //ボタンが押されたらモーダルで入力フォーム表示
                            //モーダル遷移で表示するビュー。その他の担当
                            HStack {
                                InputFormView(selectedBraver:self.selectedBraver, clientName:"", clientCompany:"" ,note:"")
                            }
                        }
                    }
                    .padding(.vertical, 20.0)
                    .padding(.horizontal, 10.0)
                    .background(Color.blue)
                    
                    ScrollView{
                        generateButtons() //ボタン(braver)の配置は座標が動的なため別途定義
                    }
                }
            }.alert(isPresented: $braveByGoogle.networkError) {  // Slack送信したらアラートを表示する
                Alert(title: Text("ネットワークエラーです"),
                      message: Text("ネットワークに接続されていないか、サーバ側でエラーが発生しています。確認のうえ再度お試しください"),
                      dismissButton: .default(Text("了解"),
                                              action: {
                                              }))
            }
            
        }
        .onAppear(){ //画面出現時に実行するメイン処理。Google APIへアクセスし、braversリストを取得
            //OAuthでGoogleに接続して予定参加者を取得
            braveByGoogle.setBraversByGoogle(viewController:UIHostingController(rootView: ContentView()))
        }
        .onReceive(timer, perform: { time in  //定期タイマーでメイン処理を再実行し自動リロードする
            braveByGoogle.setBraversByGoogle(viewController:UIHostingController(rootView: ContentView()))
        })
    }
    
    //braversが表示されるボタンを3列ずつ動的に表示。
    private func generateButtons() -> some View {
        
        var width = CGFloat.zero
        var height = CGFloat.zero
        var i = 0
        
        return ZStack(alignment: .topLeading) {
            ForEach(braveByGoogle.braverList, id: \.id) { braver in //取得済みのbraverを順に表示

                buttonItem(braver:braver)
                    .padding(.vertical, 10)
                    .padding(.horizontal, 15)
                    .alignmentGuide(.leading, computeValue: { d in
                        //print("name:\(braver.name) i:\(i) dw.:\(d.width) dh.:\(d.height)")
                        if i%3 == 0  { //4人めで左下へ座標を移動させる
                            width = 0
                            height -= d.height
                        }
                        let result = width
                        if i >= braveByGoogle.braverList.count { //全員分終了で座標をもとに戻しておく
                            width = 0
                        } else {
                            width -= d.width //ボタンのサイズ分右にずらす
                        }
                        i += 1
                        return result
                    })
                    .alignmentGuide(.top, computeValue: { d in
                        let result = height
                        if i >= braveByGoogle.braverList.count {
                            height = 0
                            i = 0
                        }
                        return result
                    })
                }
        }.frame(maxWidth: .infinity) //横幅いっぱいまで画面を使用する
    }
    //ボタン1つ1つの設定
    func buttonItem(braver:Braver) -> some View {
        Button(action: {
            print("ボタンが押されました\(braver.name)")
            selectedBraver.name = braver.name
            selectedBraver.mail = braver.mail
            selectedBraver.location = braver.location
            showInputForm = true //モーダルで入力フォームを表示
        }){
            HStack(){
                Text("")  //バランス調整のためダミーのTextを挿入
                Image(uiImage:braver.image!) //braverのサムネを表示
                    .resizable()
                    .frame(width: 50, height: 50)
                    .clipShape(Circle())
                Text(braver.name) //braverの名前を表示
                    .foregroundColor(Color.blue)
                    .font(.system(.title3, design: .rounded))
                    .fontWeight(.semibold)
                    .frame(width:130, height:70, alignment: .leading)
            }
        }
        .background(Color.white)
        .cornerRadius(10)
        .overlay(
            RoundedRectangle(cornerRadius: 10)
                .stroke(Color.blue, lineWidth: 1) //ボタンの枠線
        )
        .compositingGroup()        // shodow用にグループ化
        .shadow(color: .gray.opacity(0.6), radius: 5, x: 7, y: 7)
        .sheet(isPresented: self.$showInputForm) { //ボタンが押されたらモーダルで入力フォーム表示
            //モーダル遷移した後に表示するビュー
            HStack {
                InputFormView(selectedBraver:self.selectedBraver)
            }
        }
    }
}

//受付用の入力フォーム。入力内容をslackへ通知
struct InputFormView : View {
    
    @Environment(\.presentationMode) var presentationMode //閉じるボタン用
    @ObservedObject var selectedBraver: SelectedBraver //選択されたBraverの情報受付用
    @State var clientName = ""           //お客様の氏名保存用
    @State var clientCompany = ""        //お客様の社名保存用
    @State var note = ""                  //備考保存用
    @State private var showAlert = false  // 呼び出し結果アラート表示用
    @State private var sendError = false  // ネットワーク接続結果表示
    @State private var SUBJECT = ""    // ダイアログ表示タイトル。成功か失敗かで変わる
    @State private var BODY = ""       // ダイアログ表示文

    var body: some View {
        VStack(alignment: .leading){
            Spacer()
            HStack{
                Text("お客様情報を入力して担当者を呼び出してください")
                    .font(.title3)
                    .fontWeight(.semibold)
                    .foregroundColor(Color.white)
                    .padding(.vertical,10)
                    .padding(.horizontal,15)
                Spacer()
                Button(action: {  //閉じるボタン
                    self.presentationMode.wrappedValue.dismiss() //このフォームを閉じる
                }){
                    Text("閉じる")
                        .font(.subheadline)
                        .foregroundColor(Color.white)
                        .padding(.vertical,10)
                        .padding(.horizontal,15)
                }
            }
            Form {
                Section(header: Text(" ")){}
                Section(header: Text("お客様の氏名(必須)").font(.headline)){
                    TextField("カンタンでOK", text:$clientName)
                }
                Section(header: Text("お客様の会社名(任意)").font(.headline)){
                    TextField("カンタンでOK", text:$clientCompany)
                }
                Section(header: Text("bravesoftの担当者(任意)").font(.headline)){
                    TextField("カンタンでOK", text: $selectedBraver.name)
                }
                Section(header: Text("備考や目的など、必要に応じて入力ください(任意)").font(.headline)){
                    TextEditor(text:$note)
                }
            }
            //担当者を呼ぶボタン
            Button(action: {
                sendError = false  //エラーだった場合あらためてリセット
                //登録された内容を元にSlackへ送信
                postToSlack(braverName:selectedBraver.name, location:selectedBraver.location,clientName:clientName, clientCompany:clientCompany, note:note )
            }) {
                Text("担当者を呼ぶ(Slackで通知します)")
                    .font(.largeTitle)
                    .fontWeight(.semibold)
                    .foregroundColor(Color.white)
                    .frame(maxWidth: .infinity, maxHeight: 100, alignment: .center)
            }
            .background(Color.blue)
            .alert(isPresented: $showAlert) {  // Slack送信後のダイアログ表示
                Alert(title: Text(SUBJECT),
                      message: Text(BODY),
                      dismissButton: .default(Text("了解"),
                                              action: {
                                                self.presentationMode.wrappedValue.dismiss() //了解が押されたらフォームを閉じる
                                              }))
            }
            Spacer()
        }
        .background(Color.blue)
    }
    
    // Slackへ受付情報を投稿する
    func postToSlack(braverName:String, location:String, clientName: String, clientCompany: String, note: String) {
        
        let headers: HTTPHeaders = [
            "Content-Type": "application/json"
        ]
        let parameters: Parameters = [
            "attachments": [
                [
                    "color": "#0066FF",
                    "text": "\nお客様が「\(braverName)」さんを呼出してます!「\(braverName)」さんが気付いてない場合、同部署や近所のbraverが教えてあげる or 変わりに会議室に案内ください! 絶対に1分以上待たせないで!!",
                    "fields": [
                        [
                            "title": "bravesoftの担当者",
                            "value": braverName,
                        ],
                        [
                            "title": "お客様社名",
                            "value": clientCompany,
                            "short": true
                        ],                   [
                            "title": "お客様氏名",
                            "value": clientName,
                            "short": true
                        ],
                        [
                            "title": "会議名と場所(あくまで予想)",
                            "value": location,
                        ],
                        [
                            "title": "備考・目的",
                            "value": note,
                        ]
                    ]
                ]
            ]
        ]
        
        AF.request(SLACK_WEBHOOK,
                   method: .post,
                   parameters: parameters,
                   encoding: JSONEncoding.default,
                   headers: headers).responseString { response in

                    switch(response.result) {
                    case .success(let value):
                        sendError = false
                        SUBJECT = "担当者を呼び出しました!"
                        BODY = "1分経過しても担当者が現れない場合は、申し訳ございませんが再度お呼び出し頂くか、お近くのスタッフまでお声がけください。"
                        print("Slack接続成功:\(value)")
                    case .failure(let error):
                        sendError = true   //送信失敗アラート表示
                        SUBJECT = "ネットワークエラーです"
                        BODY = "ネットワーク接続か、サーバに問題があります。設定を確認のうえ、再度お試しください"
                        print("Slack接続エラー:\(error)")
                    }
                    showAlert = true   //アラートはどちらにせよ表示表示
                   }
    }
}

//選択されたbraverの情報を保持し共有する
final class SelectedBraver: ObservableObject {
    @Published var name = ""
    @Published var mail = ""
    @Published var location = ""
}



//背景画像をグラデーションで動かすためのView
struct AnimatedBackground: View{
    @State var start = UnitPoint(x:0, y:-2)
    @State var end = UnitPoint(x:4, y:0)
    
    let timer = Timer.publish(every:1, on: .main, in: .default).autoconnect()
    let colors = [Color.white,
                  Color(red: 190/255, green: 220/255, blue: 255/255), //水色系の色でグラデをかける
                  Color(red:  90/255, green: 180/255, blue: 255/255),
                  Color(red: 150/255, green: 200/255, blue: 255/255)
    ]
    var body: some View{
        LinearGradient(gradient: Gradient(colors: colors), startPoint: start, endPoint: end)
            .animation(Animation.easeInOut(duration:3)
                        .repeatForever()
            )
            .edgesIgnoringSafeArea(.bottom)  //これしないとキーボード出現時に下部が無効化されてしまう
            .onReceive(timer, perform: { _ in
                start = UnitPoint(x:4, y:0)
                end   = UnitPoint(x:0, y:2)
                start = UnitPoint(x:-4, y:20)
                start = UnitPoint(x:4, y:0)
            })
    }
}
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        Group {
            ContentView()
                .padding(9.0)
        }
    }
}

BraverByGoogle.swift(Google通信処理)

Google APIに接続して必要データを取得してくる処理。

//
//  BraverByGoogle.swift
//  ReceptBot
//  Google APIに接続してOAuth認証・Directoryよりbraver取得・Calendar APIより予定を取得
// 本日会議室で予定が入っているbraverのリストを維持する
//  Created by esga on 2021/05/01.
//

import Foundation
import SwiftUI
import GTMAppAuth
import AppAuth
import GoogleAPIClientForREST

//会議室(リソース)のデータ構造
struct Resource: Identifiable{
    let id =  UUID()
    let rid:  String //リソースID
    let name: String //会議室名
    let mail: String  //リソースのメールアドレス(=カレンダー取得のキー)
}
//User(Directoryから取得した社員)のデータ構造
struct User: Identifiable{
    let id =  UUID()
    let mail: String //メールアドレス
    let name: String //氏名
}

//braver一人ひとりのデータ構造
struct Braver: Identifiable{
    let id = UUID()
    let mail: String
    var name: String
    var startDate: Date  //会議が始まる時間
    var score: Int       //現在時刻からの時間的距離(単位:秒)
    var location:String  //会議が行われる場所
    var image:UIImage?   //サムネ画像
}

//Google APIよりカレンダーデータを取得して保持する。
class BraverByGoogle: ObservableObject{
    
    var nowDate:Date!
    var resourceList: [Resource] = []      //会議室リスト配列
    var userList: [String: User] = [:]     //全社員(Braver)リストDictionary メアドと名前のリストを保管
    
    @Published var braverList: [Braver] = []    //メインとなるbraverリスト配列。一意でMTGが近い順に並べる
    @Published var networkError: Bool = false   //ネットワークエラーならtrueとしviewでアラート表示する
    
    private var authorization: GTMAppAuthFetcherAuthorization? //OAuth認証済み情報。一度取得したらローカル保存
    private var currentAuthorizationFlow: OIDExternalUserAgentSession? //認証セッションの情報
    typealias callBackAlias = ((Error?) -> Void) //クロージャ共通宣言
    
    //Google Calenderから取得する予定データの構造体
    struct GoogleCalendarEvent {
        var id: String
        var name: String
        var startDate: Date?
        var endDate: Date?
    }
    private var backViewController: UIViewController?  //呼び出し元クラスのViewを保持。OAuthでログイン画面表示制御に活用
    private var googleCalendarEventList: [GoogleCalendarEvent] = []    //取得してきた予定データの配列
    
    //初期データ処理。Viewを預かり、OAuthを確立する。接続済みならローカルから取得。ない場合はOAuthログイン
    func setBraversByGoogle(viewController: UIViewController){
        
        print("init処理を行います")
        self.braverList.removeAll() //取得のたびにリストを初期化しておく
        networkError = false        //ネットワークエラー状態の場合も初期化
        nowDate = Date()   //日付をセット(デバッグの場合はデバッグ定義した日付をセット)
        
        //今日の日時(デバッグ用)をセット
        if DEBUG_TODAY != nil {
            let formatter = DateFormatter()
            formatter.dateFormat = "yyyy-MM-dd'T'HH:mm"
            formatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
            nowDate = formatter.date(from: DEBUG_TODAY)! //デバッグ用
        }
        self.backViewController = viewController //呼び出し元のViewをクラス変数で預かる
        
        //ローカルから過去のOAuth認証情報を取得してGoogleへ接続
        if GTMAppAuthFetcherAuthorization(fromKeychainForName: "authorization") != nil {
            self.authorization = GTMAppAuthFetcherAuthorization(fromKeychainForName: "authorization")!
            self.setBraversList()
        }
        
        if self.authorization == nil { //もしローカルになければOAuth接続した上でGoogleへ接続
            showAuthorizationDialog(callBack: {(error) -> Void in
                self.setBraversList()
            })
        }
    }
    
    //OAuthへの接続処理。ログイン成功したらOAuth情報をローカル保存
    private func showAuthorizationDialog(callBack: @escaping callBackAlias) {
        
        let configuration = GTMAppAuthFetcherAuthorization.configurationForGoogle()
        let redirectURL = URL.init(string: URL_SCHEME + ":/oauthredirect") //ログイン成功後戻りURL
        let request = OIDAuthorizationRequest.init(configuration: configuration,
                                                   clientId: CLIENT_ID,
                                                   scopes: SCOPES,
                                                   redirectURL: redirectURL!,
                                                   responseType: OIDResponseTypeCode,
                                                   additionalParameters: nil)
        print("requestをセットしました..\(request)")
        
        //OAuthログイン。ログイン画面を呼び出し元Viewに表示させた後、Schemeで戻す
        currentAuthorizationFlow = OIDAuthState.authState(
            byPresenting: request,
            presenting: self.backViewController!,
            callback: { (authState, error) in
                if let error = error {
                    print("接続エラーです:\(error)")
                    self.networkError = true
                } else {
                    if let authState = authState {
                        print("認証成功しました:\(authState)")
                        // 認証情報オブジェクトを生成し、ローカル保存
                        self.authorization = GTMAppAuthFetcherAuthorization.init(authState: authState)
                        GTMAppAuthFetcherAuthorization.save(self.authorization!, toKeychainForName: "authorization")
                    }
                }
                callBack(error)
            })
    }
    
    //Google APIより会議室データを取得し、参加者braverをクラス変数に保存する
    func setBraversList() {
        
        //まずは会議室情報、全社員情報をGoogle APIから取得してからresourceList,userList辞書にセット
        self.setResources(callBack: {(error) -> Void in
            self.setUsers(callBack: {(error) -> Void in
                
                //現在から30分前からの全データを取得(ちょっと前のMTGまで念の為取得する)
                let startDateTime = Calendar.init(identifier: .gregorian).date(byAdding: .minute, value: -30, to: self.nowDate)
                //現在から5時間後までの全データを取得
                let endDateTime = Calendar.init(identifier: .gregorian).date(byAdding: .hour, value: +3, to: self.nowDate)
                
                print("会議室のCalendarを取得します")
                //会議室のメアドをキーにカレンダー予定を取得、参加braverを配列へセットする
                for resource in self.resourceList{
                    print("会議室:\(resource.name),mail=\(resource.mail)の予定を取得します")
                    self.setBraverByCalendar(calendarId:resource.mail, startDateTime: startDateTime!, endDateTime: endDateTime!)
                }
            })
        })
    }
    
    //Directory APIより会議室の一覧を取得
    private func setResources(callBack: @escaping callBackAlias) {
        print("会議室取得のため、Directoryに接続します")
        let directoryService = GTLRDirectoryService()
        directoryService.authorizer = self.authorization
        directoryService.shouldFetchNextPages = true
        let query = GTLRDirectoryQuery_ResourcesCalendarsList.query(withCustomer: "my_customer")
        
        directoryService.executeQuery(query, completionHandler: { (ticket, directory, error) -> Void in
            if let error = error {
                print("接続エラーが発生..\(error)")
                self.networkError = true
                
            } else {
                print("会議室を取得します")
                if let directory = directory as? GTLRDirectory_CalendarResources, let items = directory.items {
                    self.resourceList.removeAll() //現状のリストを初期化
                    
                    for item in items {
                        let rid:  String = item.resourceId ?? ""
                        let name: String = item.resourceName ?? ""
                        let mail: String = item.resourceEmail ?? ""
                        self.resourceList.append(Resource(rid:rid,name:name,mail:mail)) //会議室配列に保管
                        print("会議室:\(name),\(mail)")
                    }
                }
            }
            callBack(error)
        })
    }
    //Directory APIよりbraver全員の一覧を取得(予定データにて氏名がnilのケースがあるので補完のため)
    private func setUsers(callBack: @escaping callBackAlias) {
        print("全User取得のため、Directoryに接続します")
        let directoryService = GTLRDirectoryService()
        directoryService.authorizer = self.authorization
        directoryService.shouldFetchNextPages = true
        let query = GTLRDirectoryQuery_UsersList.query()
        query.customer = "my_customer"
        
        directoryService.executeQuery(query, completionHandler: { (ticket, user, error) -> Void in
            if let error = error {
                print("接続エラーが発生..\(error)")
                self.networkError = true
            } else {
                print("全Userを取得します")
                if let user = user as? GTLRDirectory_Users, let items = user.users {
                    self.userList.removeAll() //現状のリストを初期化
                    
                    for item in items {
                        let mail: String = item.primaryEmail ?? ""
                        let name: String = item.name?.fullName ?? ""
                        self.userList[mail] = User(mail:mail,name:name) //社員リストに保管
                        print("社員:\(name),\(mail)")
                    }
                }
            }
            callBack(error)
        })
    }
    
    //指定されたカレンダー(会議室カレンダー)に登録されているEventよりbraverを抽出しリストへセットする
    private func setBraverByCalendar(calendarId:String, startDateTime: Date, endDateTime: Date) {
        
        //Google CalenderAPIへ接続
        print("カレンダーに接続します!")
        let calendarService = GTLRCalendarService()
        calendarService.authorizer = self.authorization
        calendarService.shouldFetchNextPages = true
        let query = GTLRCalendarQuery_EventsList.query(withCalendarId: calendarId)
        query.timeMin = GTLRDateTime(date: startDateTime)
        query.timeMax = GTLRDateTime(date: endDateTime)
        
        calendarService.executeQuery(query, completionHandler: { (ticket, event, error) -> Void in
            if let error = error {
                print("\(error)")
            } else {
                if let event = event as? GTLRCalendar_Events, let items = event.items {
                    //会議室の予定に含まれるすべての参加者をチェックしていく
                    for item in items {
                        
                        //dump("dump item attendee...\(item.attendees)")
                        let name: String = item.summary ?? ""
                        let startDate: Date? = item.start?.dateTime?.date
                        let location: String = item.location ?? ""
                        
                        if startDate == nil {continue} //日付未入力はスキップ
                        
                        print("loc:\(location),name:\(name),\(startDate!)")
                        
                        if item.attendees != nil {
                            item.attendees!.forEach({(attendee) in //予定から全参加者の抽出
                                if attendee.email != nil && self.userList.keys.contains(attendee.email!) {  //社員リストに当メアドがなければ非社員として無視
                                    
                                    //参加者braverをbraverList配列へ登録する (APIバグで氏名がnilのケースがあるので社員リストから指名を取得
                                    self.setBraver(mail:attendee.email!, name:self.userList[attendee.email!]!.name, startDate:startDate!, location:name+":"+location)
                                    print("attendee:\(self.userList[attendee.email!]!.name),\(attendee.email!),\(location)")
                                }
                            })
                        }
                        print("loc:\(location),name:\(name),\(startDate!)")
                    }
                }
            }
            //直近の予定者ほど上に表示させるため、Scoreで昇順ソート
            self.braverList.sort(by: {$0.score < $1.score})
        })
    }
    
    //名前とメールアドレスをbraverListに登録していく。重複は排除。
    private func setBraver(mail:String, name:String, startDate:Date, location:String){
        
        let replacedName:String = name.replacingOccurrences(of:"(bravesoft)", with:"") //(bravesoft)を消す
        let score:Int = abs(Int(nowDate.timeIntervalSince(startDate))) //現在時刻と予定日時の差分。スコアが小さいほど直近なので優先表示
        var braverIndex:Int? = searchBraverIndex(mail:mail, braverArray:self.braverList) //配列内に既に存在するbraverか判定
        print("setします..name:\(name),startDate:\(startDate),location:\(location),score:\(score)")
        
        //既に存在する場合で、score判定によりより直近の予定なら予定日時を上書き。存在しないなら配列に追加する
        if braverIndex != nil {
            if(self.braverList[braverIndex!].score > score){
                print("braverをより直近として上書きします..name:\(name),startDate:\(startDate),location:\(location),score:\(score)")
                self.braverList[braverIndex!].startDate = startDate
                self.braverList[braverIndex!].score     = score
                self.braverList[braverIndex!].location  = location //会議室の場所も直近の予測として保存
            }
        }
        else{
            print("braverを登録します..name:\(name),startDate:\(startDate),location:\(location),score:\(score)")
            //取得したbraver情報を格納。(サムネイルは一旦デフォルト画像.後で差し替える)
            let braver = Braver(mail:mail, name:replacedName, startDate:startDate, score:score, location:location, image:UIImage(named:"defaultuser"))
            braverIndex = self.braverList.count  //setBraverImageにこのbraverの配列上の位置を伝える
            self.braverList.append(braver)
        }
        setBraverImage(mail:mail)  //braverのサムネイル画像を取得してbraverList配列へセットする
    }
    
    //配列内の構造体に、特定のmailの値がすでに存在するかチェック
    func searchBraverIndex(mail: String, braverArray: [Braver]) -> Int? {
        return braverArray.firstIndex { $0.mail == mail }
    }
    
    //braverのサムネ画像を取得してセット.API的にbraverそれぞれ1件ずつ取得してセットしていく
    func setBraverImage(mail:String){
        print("ユーザ画像を取得します:\(mail)")
        let directoryService = GTLRDirectoryService()
        directoryService.authorizer = self.authorization
        directoryService.shouldFetchNextPages = true
        let query = GTLRDirectoryQuery_UsersPhotosGet.query(withUserKey: mail)
        
        directoryService.executeQuery(query, completionHandler: { (ticket, directory, error) -> Void in
            if let error = error {
                if error.localizedDescription.starts(with:"Resource Not Found") {
                    print("画像が見つかりません(404)")  //画像が無いと404が返されるが、エラー処理にはしない
                }
                else{
                    print("接続エラーが発生..\(error)")
                    self.networkError = true
                }
            } else {
                print("ユーザ画像を取得します")
                if let directory = directory as? GTLRDirectory_UserPhoto, let photo = directory.photoData {
                    var urlSafePhoto = photo.replacingOccurrences(of: "-", with: "+")  //URL Safeを解除したBase64文字列に変換
                    urlSafePhoto     = urlSafePhoto.replacingOccurrences(of: "_", with: "/")
                    let imageData = Data(base64Encoded: urlSafePhoto)
                    let braverIndex:Int! = self.searchBraverIndex(mail:mail, braverArray:self.braverList) //このbraverの場所を探す
                    self.braverList[braverIndex!].image = UIImage(data:imageData!)!  //配列内に既に存在するbraverか判定
                }
            }
        })
    }
}

以上2つの主なSwiftファイルだけで受付アプリが出来て、100万円の価値を発揮する。

Enjoy!

コロナを正しく恐れるための7つの事実 #29

最良の思考は孤独の中でなされる。最悪の思考は騒動の中でなされる。
トーマス・エジソン

コロナ禍もそろそろ1年半。ようやく「ワクチン接種したよ」という声も周囲から聞こえ始め、長いトンネルの先に希望の光が見えてきた。

でも、「夜明け前が一番暗い」という言葉のように、
最近の日本はオリンピックだの変異株だので暗中大騒ぎになっている。

とあるアンケートでは8割の人がオリンピックを中止すべきと答えた。
オリンピックに前向きな発言をすると叩かれる空気を感じる。

・・でも、僕が思うにオリンピックは開催して良いと思う(延期のほうがベターだが)。

こんな些細な意見でも言い出しにくい社会は、もはや異常事態とも言える。
ついには感染を広げたことを理由に自殺する人まで出てきてしまった。

療養中の女性自殺「職場に感染広げたのでは…」思い悩む言動、メモも発見

そこで今回はコロナの9つの事実を知ることで、少しでも読者の不安を解消したい。そして「日本人は世界で劣ってもないし、むしろ誇っていい」ことも示したい。

リスクはリスクとして正しく認識して、正しく恐れよう。

①日本人はコロナに強い

日本人はコロナに強い。

これは先進国(G8)における2021年5月時点のコロナ死亡者数の比較グラフ。

日本より人口の少ないイギリスやフランスに比べても1/10程度に抑えている。

2020年2月に世界中が感染した時に生き残るのは日本人と書いた通りになってしまった。
(※アジア人はもともと体質的にコロナ耐性が強い説もある)

ただし、いくら感染を抑えても経済が死んでしまえば今度は失業者と自殺が増える。

そこでいくと実は日本は経済ダメージも少ない。下記のグラフはG8の経済ダメージ(実質GDPの落ち込み)を比較したもの。(単純な昨年対比ではなく、「2019年と同率で2020年が成長した場合と、実際の落ち込みの差異」)

日本が最も経済ダメージを抑えている。アメリカやロシアも抑えているが代償として死亡者が多い。逆にカナダは死亡者は少ないが代償として経済が落ち込んだ。

日本は死亡者も経済の落ち込みもG8で1番少ないのだ。

②日本は「米国の日常レベル」の水準を堅持

2021年4月、米国でワクチン摂取が進んだ結果、テキサスの大リーグチームは観客数制限を完全撤廃し、3.8万人(ほぼ満員)収容で試合は開催された。

この時点のアメリカで1日の感染者数は6万人。そして2ヶ月経った現在は1日感染者数は3万人以下に減少。もはやすっかり日常を取り戻しコロナの話題は過去のものになりつつある。

下記は「コロナ」の検索件数の日本とアメリカの比較。

これを見ると、「アメリカはコロナに勝利。日本はまだまだ」と思ってしまうことだろう。

ところが、現在(5/30)でも1日あたりの感染者数はアメリカが日本より多い。

日本は1日4000人程度で減少傾向。アメリカも減少中だがまだ新規感染は1日2万人もいる。

そう、日本は「アメリカの日常レベル」を一度も超過せず堅持してきたのだ。

みんなでキッチリ自粛しコロナを制圧。ワクチンも他国に一歩譲る日本。

かっこいいではないか? みんな、本当によくがんばっている。
なすべきことは犯人探しではなく、ヒーロー探しなのだ。

スタジアムを満員にしてノーマスクで試合を楽しんでいるアメリカ(1日の感染者:2万)。
2ヶ月後のオリンピックは中止にすべきだと騒いでいる日本(1日の感染者:0.4万)。

なんとも対照的だ。

③日本は法でなく「思いやり」で自粛できる

日本がコロナと経済を両立できた理由は、GoToや給付金など財政出動も影響しているが、さらに重要なことは

「一人ひとりが思いやりで最適解を形成していく日本的カルチャー」

にあると思う。

日本は、憲法で人権が保護されており他国の戒厳令(=非常事態宣言)のように個人の行動を強制的に制限することは出来ない。

下記に各国の非常事態宣言を整理した。
日本だけが強制ではなく「自粛お願いします宣言」を発令し、誰もが自主的に自粛した。

5月末現在、緊急事態宣言下でも街に人が溢れている。それでも第4派は収束しつつある。

街を歩いているのはエゴの塊ではなく、思いやりである。
感染は避けたい、でも少しは出かけたいし、いきつけの店は少しでも助けたい。

100人いれば100通りの自粛の仕方がある。統一のシンプルなルールを当てはめるより、それぞれ思いやりベースの自粛行動をしたほうが合理的ですらあるのだ。

ルールには限界がある。ルールでは無限にある状況を指示しきれない。密を避けた青空パーティならやってもいいかもしれないし、逆に高齢者等の健康に不安がある人が近くにいるなら、緊急事態宣言が出ていなくても強い自粛をすべきだろう。それらすべてをルール化することはできない。

また強引なルールを矯正した結果、経済も落ち込むだろうし、反発心から逆に地下で盛大な3密パーティが秘密の内に開かれてしまうかもしれない。

厳格なルールではなく、想いやりをベースとした緩やかなカルチャーで、我々はワクチンも使わずコロナを4回もしっかり抑え込み、経済打撃も最小限に抑え、そして現在はスゴい勢いでワクチン接種を進めているのだ。

もっと誇ってよいのでは?

④コロナ死亡者の85%は70代以上

下記、厚生労働省の公表資料を元に作成されたグラフは、コロナで死亡した人は、「実はコロナがなくても死亡した可能性」を示している。

2019年の全ての死亡者の85%程度は70代以上
2020年のコロナ死亡者の85%程度は70代以上

※引用:ダイアモンド社「日本のコロナ自粛がどう見てもバランスを崩している理由」

もし、コロナで50,60代の死亡率が大きく高まったとしたら、「コロナは死に至る病」と強く恐れる必要がある。しかし、データを見る限りそうではない。

コロナは死亡リスクが高い人の体力を奪い死亡させてしまう感染病なのだ。逆に言えば死亡リスクが少ない人を死亡させてしまうほど強くはない。

だからと言ってコロナは恐ろしくないと言うつもりはない。
緊急事態宣言等で抑え込み少しでも犠牲を防ぐべきだ。

ただ、「コロナはどんな人でも死に至る伝染病」と言うなら恐れ過ぎだ。
「コロナがきっかけだったが、天寿をまっとうした」と本人は感じているかもしれない。

⑤コロナで日本の死亡者は減少した

下記、厚生労働省の公表資料を元に日経新聞が作成したグラフによれば、

2020年はコロナで死亡者が増加したが逆にインフル他を理由とする死亡者は減少。
結果として、2020年は例年より年間死亡者は大きく減少した。

引用:日経新聞「年間死亡数11年ぶり減 コロナ対策で感染症激減」

ざっくり言うと、2020年に起きたことは

コロナ死亡者が3000人増加
インフルエンザ死亡者は5000人減少

インフルエンザで死亡する可能性があった層が、コロナで死亡したと見ることができる。
それにしてもこれだけ恐れられるコロナが逆に死亡者を減少させたというのは皮肉的。

勿論それでもなお、コロナは恐ろしい伝染病だ。
しかし「インフルエンザと比較できないほど非常に恐ろしい」という程でもなさそうだ。

⑥コロナの致死率はインフルエンザの3倍

ワシントン大学の研究によればコロナの致死率はインフルエンザより3倍高いそうだ。

ダイアモンド「新型コロナ、致死率はインフルエンザよりもはるかに高い」

はるかに高いといえど、3倍である。

日本社会のコロナに対する恐怖心はインフルエンザの100倍ぐらいではないだろうか?

あなたの体力では、3回インフルエンザになると1度は重症化するか?
もし答えがNOであれば、コロナはあなたにとっては大きなリスクではない

世界最強の体力自慢が集まり無観客で実施するイベントを中止する必要があるだろうか?
コロナへの恐怖やストレスのはけ口として現代の魔女狩りにあっているとは言えないか?

⑦若者感染者26万人中、死者は85名

2021年3月24日時点の厚生労働省の発表によれば、約1年間でコロナに感染した若者は26万人いる。そのうち死亡したのは85名だ。

糖尿病の基礎疾患を持つ20代の力士が亡くなったというニュースもあったが、26万人中、何らかの基礎疾患を持つ85名だけが亡くなったと思われる。

日本では毎年30万人が交通事故にあい、3000人が死亡する。若者にとってはコロナより自動車のほうがよっぽどリスクが高いのだ。

ちなみにここで言う「若者」には「〜49歳」まで含めている。

下記に厚生労働省の発表からこの1年間の感染者1万人あたり死亡率を世代ごとに整理した。
たとえば30代の1万人がコロナに感染したとして、死亡するのは2.6名だ。

若者にはコロナのリスクは小さいが、80代なら40代の10倍以上まで死亡リスクが高まる。
それでも80代以上の8割以上はコロナから回復して帰ってくる

お正月のお餅から始まり、あらゆる行動には必ず死亡リスクがついてまわる。
大切なことは思考停止にならずにリスクの大きさを正しく知っておくことだ。

最優先は高齢者へのワクチン

9の事実から分かることは、コロナ問題とは(既によく言われてるように)「高齢者(や健康不安な人)の感染をいかに防ぐか」に尽きる問題である。

若者の感染が怖いというよりは感染者数が増えて高齢者に感染させるのが特に怖いのだ。

つまり高齢者へのワクチン接種が完了する7月末が、我々がコロナに勝利する時だ。
政府は7月末までに完了できる見通しの中でオリンピックを開催するつもりなのだ。

コロナを正しく恐れるなら、高齢者との接触が多い人はやはり強めに自粛するべきだろう。
接触機会が少ないのであれば、過度な自粛より、バランスを見て経済を回すべきだ。

経済経済というと社長のポジショントークのようだが、命の問題でもある。

さっきの統計によると若者の3000人がコロナに感染すると、1名が死亡してしまう。
そして総務省の発表資料を元にした記事によれば300人が失業すると、1名が自殺してしまう

若者にとってはコロナ感染より失業のほうが大きな問題である。
自分自身、失業するよりコロナに感染するほうがマシだと思ってしまう。
コロナで壊滅的な打撃を受けた中小企業の被害は少しずつ大きくなっている。これからは失業者による自殺や犯罪のほうがコロナより大きな問題になるかもしれない。

東京は今日も緊急事態宣言の中、街に電車に人が溢れているが、それでも感染者は減り続けている。よしこの調子だ。経済も大事にしよう。感染者数が日常レベルのうちはそれぞれ思いやりベースで最適なバランスで行動すれば良い。(もし圧倒的に感染者数が増えてしまったら、他国同様により強い自粛措置が必要な可能性はあるが、今回は出番がなさそうだ)

感染者数が増えてきたら緊急事態宣言を出し、それぞれがバランス良く自粛する日本の作戦は、一見場当たり的だがとっても合理的であり、実際にワークしているのだ。

良いぞ日本。

さて、それにしてもこのままオリンピックは中止になってしまうのだろうか?

見えない敵を前に肥大化し暴走する日本社会のヒステリーが、
この日を夢に幼少から走り続けたアスリートの一生の晴舞台を壊さぬよう願う。

【コロナ後のイベントはどうなる?】 リアルに戻るイベント or オンライン化するイベント #28

ようやく「ワクチン」という希望の光が差してきた今日この頃、つよつよビジネスパーソンとしては、コロナ後の社会を予想して先回りしておきたいところです。

イベントはオンラインで十分なのか?

あらためて、コロナは人類史上稀に見る社会実験だったと思います。(いまだ進行中)

中でも特に関心の高いテーマの1つが

「イベントはオンラインで十分なのか?」

です。

この1年で相当数のイベントがオンライン開催となり、その中には成功したもの、イマイチだったもの、など様々な結果がでました。

そしてコロナ後はどうなるのか? 

イベントにもよりますが、大まかに分類すると、「リアルに戻る」 or 「オンライン化する」 or 「ハイブリッド化する」のいずれかに分かれそうです。

入社式、セミナー、展示会、結婚式など、それぞれどのように進化するでしょうか?

そこで、イベントをDXする自社プロダクト「eventos」を提供し、東京ゲームショウや東京モーターショー、東京ガールズコレクション等、多くのイベントに関わり(たぶん)日本一イベントを考えているIT起業家として、コロナ後のイベントを予想してみたいと思います。

ちなみに、僕らは2015年から「イベント×DX」を進めてきましたが、コロナ禍で「オンラインイベント」のニーズが急に高まり、問い合わせ数は倍以上に跳ね上がりました。東京ゲームショウ2020は初のオンライン開催となり、eventosでオンライン会場を担当しました。その他にも数多くのオンラインイベントを支援しました。

2020年は誰もが一度はオンラインイベントを体験したのではないでしょうか?イベント業界はDXが遅れていましたが、いきなり5年ぐらい未来にワープした感覚です。

ただし、「これからはすべてがオンラインだ」などと豪語する人もいますがそれは短絡的です。(例えば、20年前ぐらいに「10年後には本屋もテレビもなくなる」と豪語する人もいましたが、意外となくなっていません。)

変わるもの、変わらないもの。グラデーションをイメージし、解像度を上げましょう。

ということで、コロナ後のイベントがどうなるか、僕の予想を発表します。

リアル・オンライン対比マップ

さて、さっそく結論ですが、リアル・オンライン対比マップをつくりました。

(クリックで拡大)


イベントを分類するために、2つの軸を作りました。

縦軸の「↑参加・体験 VS 受動・視聴↓」

縦軸はイベントへの参加スタンスの違いです。

上に行くほど、食事をしたり交流したり、リアルで体験するイベントで、下に行くほど、ただ視聴するだけだったり受身なスタンスで参加するイベントです。

参加・体験型のイベントほど、オンライン化が難しく、リアルで参加したくなります。

例えば、

・手にとったり、食べたりはオンラインではできない(例:肉フェス)。
・議論や交流はやっぱりリアルの方が盛り上がる(例:ビジネス交流会)。

という感じです。

横軸の「←論理・仕事 VS 感情・生活→」

横軸はイベントの目的や性質の違いです。

右に行くほど、感動したり生活に身近なtoC系イベントで、左に行くほど、論理的で、仕事系のtoB系イベントです。

論理・仕事型のイベントほど、オンライン化しやすい傾向があります。

例えば、

・ビジネスの成果がでるならオンラインでも別に構わない(例:説明会)
・合理的に結論が出ればオンラインでも目的は達成できる(例:株主総会)

という感じです。

こうして2つの軸をもとにA,B,C,Dの4つのグループに分類しました。

そしてそれぞれのグループに個人的見解で各イベントをマッピングしました。
(どのイベントがどのグループに属するかは人によって意見が分かれると思います。)

グループA: 「絶対リアル」グループ

グループAは、「参加・体験型」×「感情・生活」のタイプです。

このグループは絶対にリアルで開催したいグループです。

たとえば結婚式。リモートでやろうと思えばできますが、起立して乾杯、写真ラッシュ、ブーケトス、新婦スピーチ等々、リアルで体験する感動は到底オンラインで再現できません。

実際、2020年に↓のイベントは、オンライン開催ではなく「中止」になりました。

肉フェス、フジロックフェス、ねぶた祭り、当社社員の結婚式・・。

グループB: 「リアル+オンライン」グループ

グループBは、「受身・視聴型」×「感情・生活」のタイプです。

このグループは、オンラインでもいいけど、リアルのほうが嬉しいグループです。

2020年のサザンや東京ゲームショウ等のオンラインイベントに参加した人は多く、「意外にオンラインでも楽しめた」という声をよく聞きました。これは新しい発見でした。

でも、「やっぱリアルで参加したい」という声もよく聞きました。他のリアルが皆無だったからオンライン参加したけど、リアルがあるならそっちを選択する、という人は多そうです。「オンラインはリアルを完全には再現できない」ということもはっきりしました。

オンライン vs リアルでは大きく体験価値が違うので、オンラインのチケット代はリアルの半額以下で提供されることが多くてリーズナブルです。そして、世界中どこからでも参加できる。会場キャパ制限が無い、などオンラインならではのメリットもあるので、オンライン参加のニーズはそれはそれで定着すると思います。

グループBの2020年のオンライン開催の事例は↓のとおりです。

サザンライブ:18万人がオンライン視聴(売上6.5億円)。
東京ゲームショウ:公式番組の総視聴数が3160万回(無料)。
東京ガールズコレクション:248万人がオンライン視聴(無料)。

あらゆるこの手のイベントがオンライン開催に挑戦したことは大きかったです。
グループBはコロナ後も基本リアル、そしてオンライン視聴も積極的に、となりそうです。

グループC: 「リアルorオンライン」グループ

グループCは、「参加・体験型」×「論理・仕事」のタイプです。

このグループは基本はリアル、場合によりオンラインでもOKなグループです。

例えば社員総会や記者会見などは、一応オンラインでもできるけど、リアルのほうが盛り上がるし効果も高いので、基本的にはリアルを志向します。

実際、コロナ禍でこのグループは大量にオンライン開催に移行しましたが、オンラインの限界を感じるという声もよく聞きました。

例えばとあるオンライン展示会では下記のような声が上がりました。

主催者の声:参加者は大量に来たがマッチングは少ない。誰が興味ある人かわからない。
出展者の声:ウェビナー視聴は多数あったが、商談には繋がらないし、印象も残らない。
来場者の声:ウェビナーは参考になったが、出展はあまり見ないし、飽きてきた。

このグループはリアルが基本ですが、例えば世界中からリモート参加させたい場合や、台風でリアルが危険な場合など、オンライン開催という選択肢は今後も有効と思います。そして、オンラインでも工夫次第で十分な成果を出すことは可能です。

介護大手のブティックスは365日開催のオンラインイベントCareTEX365をeventosで提供。「新市場の開拓で半年間で1億円の売上見込」という画期的なIRを発表しました。
(2020年11月IRより)

オンラインイベントの可能性を大きく感じる事例です。

リアルは短期開催。オンラインは長期開催とし、別々に扱っているところが特徴です。

グループBとグループCを比較してみると、どちらも「リアルとオンラインのハイブリッド」と言えるものの、その組み合わせ方は大きく違います。

グループBのハイブリッド:リアルを主体に、オンラインでも視聴可能(プロ野球)
グループCのハイブリッド:リアルorオンライン開催どちらかを選択(ビジネス交流会)

このグループは基本リアルに戻るものの、オンラインも織り交ぜる形に進化しそうです。

グループD: 「基本オンライン」グループ

グループDは「受身・視聴型」×「論理・仕事」のタイプです。

このグループはオンラインで十分目的が果たせるグループです。

例えば入社説明会のように、内容理解が主目的で、感動したり交流したりする必要がないイベントは、リアルで開催する意味がありません。

全員がオンラインを体験したコロナ後においては、このグループのイベントをリアルで開催すると、逆にストレスになりそうです。

たとえばセミナーは「見込み顧客を発掘する」という目的があります。そしてその目的を果たすには、リアルよりオンラインの方が効率が良かったりします。

あるセミナー上手な会社は、リアルからオンラインへの切替で↓の変化がありました。

○開催コストが下がった。 →セミナー回数が倍増。
○参加コストが下がった。 →参加者数が3倍に。
△参加者の熱量は薄い。  →商談化率は50%に半減。

◎結果、売上は2〜3倍に増加

リアルに比べて参加者の熱量は低くなるものの、参加者数と開催数が増えるので、結果的に受注を倍増することができました。

このグループはコロナ後も基本オンラインでやり続けるでしょう。

イベントをNew Normalへ

コロナ後のイベントを予想してみましたが、意外とリアルが戻りそうな結果になりました。
また、グループB〜Dのオンライン開催という新市場はコロナ後も拡大しそうです。

最後に強調したいのは、今後は「リアル vs オンライン」という二項対立ではなく、単に「イベントのDXを完遂しよう」ということです。テクノロジーでリアルはもっと便利に出来るし、オンライン開催も日常的に活用しましょう。

「イベントがアナログで不便」

そんな問題意識から2015年にeventosを開発して、イベントのDXを推進してきました。

イベントでよく見る↓のような不便なシーンは、デジタル技術で改善できます。

入場前の大行列
名刺2枚で入場
大きな地図で探すの大変
チラシをもらって袋に→結局捨てる
名刺交換 → ペンでメモ書き

リアルができなかった2020年、イベントDXの可能性は大きく開かれました。誰もがオンラインイベントを体験したこの1年は、イベントDXの起点となる革命的イベントでした。

そしてこの1年、リアルイベントが出来なかったことで、新しい出会いや体を震わせる感動などが減り、「社会の幸せの総量」は大きく減少してしまったと感じませんか?

「人類にイベントは必要である」ということもまた、はっきりした1年だったと思います。

安心してイベントが出来る日常は必ず戻ってきます。

イベントで社会をもっと元気にすべく、我々はテクノロジーを活用していきます。

手伝ってくれるエンジニアやイベンターの皆様、ぜひ当社の門を叩いてください!

————
この記事はイベントの役に立つ「eventosブログ」に寄稿予定だったものの、長くなりすぎたのでこちらに掲載することになったのだった笑

【Firebase+Node.js+LINE SDK】230行で東京都コロナ新規感染者数通知botを開発 (ソース付) #27

Firebase,Node.js等で開発中

コロナ感染者数を追いかけた1年

コロナに始まりコロナに終わった2020年、気づけば毎日、東京都の新規コロナ感染者数をチェックするのが日課になっていた。経営者たるものいち早く社会情勢をキャッチし舵取りをしなければならない。

毎日、15時頃に発表される新規感染者数の数値を、ニュースサイトをリロードしながら待つ。なぜか発表が15時より遅れる日もあって、やきもきしてしまう。

1年に1度、長期休暇中に一人ハッカソンを開催して何かしら開発してきた僕にとって、今年のテーマは迷いなく決定。この感染者数確認ルーチンの自動化だ。

毎日5分このルーチンにかかるとしたら1年間で実に30時間。これは自動化しなければ!

かくして年末年始、「東京都の新規コロナ感染者数を毎日毎分チェックし、新着感染者ニュースを検知したらいち早くLINEで速報を通知するサービス」を開発することにした。

一人ハッカソンの目的は新しい技術の学習でもある。今回は初めてFirebaseをつかってサービスを開発してみることに。

結果として、Firebaseの生産性はめざましく、コア部分はわずか3日で開発完了。ソースコードにして230行程度だ。当初想定の半分ぐらいの時間で完了したのだった。以下に開発・リリースまでの大まかな流れをまとめた。何かの参考になれば幸いだ。

コロナ感染者数通知ロボット「コロッチβ」

開発したサービスはその名も「コロッチβ」。サービス紹介サイト作った。

コロッチβ 〜東京都のコロナ感染者数通知サービス〜

※2023.04.30 コロナ収束につきサービス終了しました!※

無料で使えるので試しに利用登録してみて欲しい。(コロナ収束次第終了予定)

利用登録は下記QRをLINEで友達追加するだけ。とってもカンタン。

※2023.04.30 コロナ収束につきサービス終了しました!※

コロッチβがやってくれることは・・・

①15時頃に起動
②主要メディアを毎分チェックし東京都×コロナ関連記事を探す
③新着記事を発見したらLINEで知らせる
④2件通知したら本日分は終了
⑤たま〜にboketeのボケをランダムにLINEしてくる笑

という感じだ。

ちなみにサービスサイトはSTUDIOというWeb構築サービスで半日で構築。便利。

Firebaseの良いところ3つを解説

まだ未経験の人のために、Firebaseのナイスなポイントを3つ紹介しよう

①サーバ構築が不要
②よく使う機能が揃ってる
③負荷対策もバッチリ

①サーバ構築が不要

なんといってもこれにつきる。サーバレス開発。もともと「スマホアプリ側は開発するけどサーバ側はシンプルにデータ保存だけだから、いちいちサーバ構築&開発しなくて済ませたい」という怠惰系ニーズに応える思想で構築されているのがFirebase。サーバ構築にかかるストレスをとにかく削減できる。

例えばこれまで必須だった、サーバOS、Web、DB、バッチ、テスト、ログ、分析などの構築や設定が全部不要になるのだ。そしてもう1つのメリットとして、環境にまつわる設定をソースコードに記述することで、管理がシンプルでトラブルが避けられる。

これまでは、プログラムと環境構築がバラバラにやっていたので、「ソースの移行はカンタンだけど、環境構築どうやったか忘れちゃった」みたいなことがよくあった。

たとえば今回のコロッチβでのバッチとテスト環境の設定は下記のようにソースコードに書いて済ませた。DBの定義なんかもソース内で書いた。(今まではDBも事前構築が必要)

このソースをdeployコマンドで反映するだけでバッチ処理の設定が完了する(下記の例は、本番は13:00-19:00まで毎分実行、テストは1日中、毎分実行するという設定)

サーバレスなサービスの中でもFirebaseは特にシンプルで使いやすいと感じる。

②よく使う機能が揃ってる

アプリ開発者がよく思う、「こんな機能がもともとあれば楽だな〜」がガシガシ機能実装されていっているから、開発工程が大きく削減できる。

下記に提供されている機能をざっくりまとめてみた。

Cloud Firestore カンタン高速なDB
Cloud Functions APIやバッチ処理開発環境
Authentication ログイン・認証機構
Hosting WEBページ配信
Cloud Storage 画像や動画を保存・配信
Crashlytics 障害レポート・管理
Performance Monitoring パフォーマンス状況の表示
Test Lab 実機テストの代行
Google Analytics アクセス解析
Predictions ユーザ行動の予測・セグメント分析
Cloud Messaging プッシュ通知の配信
Remote Config 設定ファイルの管理・更新
Dynamic Links アプリ内部やDL用URLへのリンク
ML Kit 機械学習
A/B Testing A/Bテストの実行・分析

③負荷対策もバッチリ

サーバで意外と大変なのが負荷対策だ。事前のテストで本番当日の大量アクセスを再現するのが難しかったりするし、そもそも負荷の高いサービス運営経験が豊富な人は少ない。サーバを増やせば増やすほど構造が複雑になり、大企業のような様相を呈してくるのだ。

「シンプルな設計で作っておけば、自動でサーバを増設してさばいてくれる」

というFirebaseの設計思想は、まるでコンビニチェーンのように一気に店舗数を増やして大量の客(リクエスト)に対応できるのだ。

FirebaseのDBサービスである「Firestore」のドキュメントによれば、

完全な自動スケーリングに対応しています。現在のところ、約100万件の同時接続と、毎秒10,000回の書き込みまでのスケーリング制限があります

とのことだ。つまり大抵のサービスは負荷の心配がなくなる。これはアプリ開発者にとっては非常に嬉しいだろう。気になる料金も、下記の通り1週間程度で1万リクエストをさばいても14円程度だった。他のサーバ環境のサービスよりも、全然安く済んでしまう。

Puppeteerでスクレイピング&LINEで通知

Node.jsで提供されているスクレイピング用ライブラリのPuppeteerは、ライブラリ名がわかりにくいという点を除けば、簡単にスクレイピングができてとっても便利。また、LINEが提供しているNode.js向けのMessaging SDKも、予想よりも全然簡単に利用できた。

下記のソースを見てほしい。

「ボケてのピックアップにアクセスしてランダムで1ボケ拾ってLINE通知」

という処理が、たったこれだけのコードでかけてしまったのだ。

こちらはLINEの開発者向けリファレンス。日本人らしい丁寧なドキュメント

LINEとの連携設定。さすがUI/UXに定評のあるLINE。迷うことなく直感的に完了できた。

Firestoreにデータを記録

1日にLINE通知は2件も送れば十分。(あまり頻繁にくるとブロックされる)。LINEを送りすぎないようにするため&メディアへのアクセス回数も最低限にするためにも、新着記事の取得を記録しておき、本日のLINE通知が終了したらこれ以上、メディアへアクセスしないという制御をしておく。

Firestoreへのデータ保存のコードはこんな感じで簡単。NoSQLなのでDB構造もソースコード中で指定してひたすら保存していく。

保存されたデータは下記の感じ。covid_titlesというコレクションの中に1日1ドキュメントずつ追加していく。

ドキュメントの中身。メディアごとにドキュメント内コレクションを作成し、その中に記事取得の詳細を記録していく。

コロッチβの仕組みの図解

今回の仕組み図を解説すると下記のような感じだ。

ローカルPCにFirebase、Node.jsの環境を整備し、index.jsをプログラミングするだけで、環境設置が完了してしまうもだ。これまでLinuxサーバの設定やミドルウェア構築で大変だった時代と比較しても、カンタンになったものだ。。

ソースコードはコチラ!

indes.jsのソースコードを掲載。(LINEアカウント関連は伏せた)。ご参考に!


const functions = require('firebase-functions');
const puppeteer = require('puppeteer');
const admin = require('firebase-admin');
admin.initializeApp(functions.config().firebase);
const firestore = admin.firestore();
const line = require("@line/bot-sdk");

var isDEBUG = false;           //デバッグモード時はtrueとなり、各処理がデバッグモードで実行
var colName = "covid_titles";  //使用するfirestoreのコレクション名

//対象サイトドメイン, 記事一覧ページのパス, 記事タイトルのCSSクラス名, タイトルタグからAタグ位置のXPath記述, の配列
var mediaURLs = [
  ['www.fnn.jp'         ,'/category/news/',   '.m-article-item-info__ttl', './parent::div/parent::a'],
  ['www3.nhk.or.jp'     ,'/news/catnew.html', '.title',                    './parent::a'],
  ['www.nikkei.com'     ,'/news/category/',   '.m-miM09_titleL',           './parent::a'],
  ['www.asahi.com'      ,'/news/',            '.SW',                       './self::a'],
  ['news.tv-asahi.co.jp','/news_society/',    '.list-text',                './parent::a']
];

//LINEメッセージ配信用の設定
const config = {
  channelAccessToken: '**************',
  channelSecret:'**************'
};

//LINE関連 デバッグ用の別アカウント
const config_DEBUG = {
  channelAccessToken: '**************',
  channelSecret: '**************'
};

//firebaseのタイムアウトと使用メモリの設定
const runtimeOpts = {
  timeoutSeconds: 50,
  memory: '2GB'
}

/**
  コロナ記事チェック定期処理を本番モードで開始するよう登録する。13:00-19:00の間で毎分処理
 */
exports.covidCheck = functions.runWith(runtimeOpts).region('asia-northeast1').pubsub.schedule('every 1 minutes from 13:00 to 19:00').timeZone('Asia/Tokyo').onRun(async(context) => {

   isDEBUG = false;
   console.log("本番モードで定期処理を実行します");
   await covid19Check();
});

/**
  コロナ記事チェック定期処理をデバッグモードで開始するよう登録する
 */
exports.covidCheck_DEBUG = functions.runWith(runtimeOpts).region('asia-northeast1').pubsub.schedule('every 1 minutes').timeZone('Asia/Tokyo').onRun(async(context) => {

   isDEBUG = true;
   console.log("デバッグモードで定期処理を実行します");
   await covid19Check();
});

/**
  対象サイトに対してクローリングリクエストを送信していく
  */
async function covid19Check(){

  //クロール対象メディアすべてに対して調査
  for(let media of mediaURLs) {

    //YYYYMMDDをキーとし、ドキュメントを取得 > 更にサブコレでドメインをキーとして、本日すでに記事保存済みか判断
    const col = (!isDEBUG ? colName : colName+"_DEBUG");
    var docRef = firestore.collection(col).doc(YYYYMMDD()).collection("media").doc(media[0]);
    var snapShot = await docRef.get();

    //まだ今日firestoreに記事が登録されていないなら、記事を取得する
    if(snapShot.exists){
      console.log("既に記事が保存済みです:"+docRef.id);
    }
    else{
      await mediaRequest(media[0],media[1],media[2],media[3]); // スクレイピング
    }
  }
}

/**
  メディアへスクレイピングURLを送りコロナ記事を判定
  @param domain     記事掲載サイトのURL,IDとしても使う
  @param titlePath  記事一覧ページのパス
  @param titleClass 記事タイトルのCSSクラス名
  @param pathA      タイトルからAタグ位置のXPath記述
 */
async function mediaRequest(domain, titlePath, titleClass, pathA) {

  console.log("mediaRequest:%s,%s,%s,%s", domain, titlePath, titleClass, pathA);

  const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox']});
  const page = await browser.newPage();
  await page.goto('https://'+domain+titlePath); //記事を取得するためメディアサイトへ接続

  console.log('接続しました:%s',await page.title());

  const titleLists = await page.$$(titleClass);  //記事タイトルのクラス名を指定して全タイトル取得

  //全記事タイトルについて、東京コロナ感染関連の記事があるか判定
  var maxTitle = titleLists.length;

  //(本番時は)上から10件の最新記事のみ記事取得をする。デバッグ時は全件取得。記事が10件無いケースを想定してmin
  if(!isDEBUG){ maxTitle = Math.min(titleLists.length,10); }

  for (let i = 0; i < maxTitle; i++) {  //昨日の記事を拾うことがあるので、上から10記事のみチェック

    const temp = await titleLists[i].$x("./text()"); //子要素も含んでいるので、本要素のtextのみ切り出し
    const titleProp = await temp[0].getProperty('textContent'); //記事タイトルタグのテキスト取得
    var titleText = await titleProp.jsonValue(); //テキストへ変換
    titleText = titleText.trim(); //前後の余白を除去

    //記事タイトルの特定文字(東京とか)から、対象記事かどうかを判定
    if(/東京/.test(titleText)&/感染/.test(titleText)&/人/.test(titleText)){

      console.log("title=%s",titleText);
      const parentA = await titleLists[i].$x(pathA);  //タイトルのタグからAタグへの相対距離をXPathで指定

      //Aタグが存在すれば(必ず存在するはずではある) タイトル保存&プッシュ送信へ
      if (parentA.length > 0) {
        const hrefProp = await parentA[0].getProperty('href'); //AタグよりURL取得
        const hrefText = await hrefProp.jsonValue();
        console.log("hrefText=%s",titleText,hrefText);
        await covidTitleAction(domain,titleText,hrefText);  //コロナ記事発見時の処理
        break;// 1メディア1記事配信した時点で終了する
      }
    }
  }
  return await browser.close();
}

/**
  コロナ記事発見時の処理
  @param domain   記事掲載サイトのURL,IDとしても使う
  @param title    記事のタイトル
  @param titleURL 記事のURL
 */
async function covidTitleAction(domain,title,titleURL){

  console.log("ドキュメントを登録します %s,%s:",title,titleURL);

  var titleCount = 1;  //本日の記事とロク数を保管

  //YYYYMMDDをドキュメントキーとする
  const col = (!isDEBUG ? colName : colName+"_DEBUG");
  var docRef = firestore.collection(col).doc(YYYYMMDD());

  //本日何件記事保存しているか(3件以上は送らない制御用)
  await docRef.get().then(doc => { titleCount = doc.data().count+1;})
                    .catch(err => { console.log('ドキュメント読み込みエラー', err); });

  docRef.set({
              updated : new Date(),
              count   : titleCount  //今日の記事登録件数を保存
            });

  docRef.collection("media").doc(domain).set({  //1記事ごとにサブコレとして登録
        title    : title,
        url      : titleURL,
        created  : new Date()
       }
  );

  //本日配信がまだ2件未満なら、記事をLineメッセージで全員へ配信
  console.log("Lineメッセージで配信します %s,%s:",title,titleURL);

  //Line配信の準備 本番かデバッグモードかで配信先を切り替える
  const client = new line.Client((!isDEBUG ? config : config_DEBUG));

  //(本番時は)本日最初の2記事のみプッシュ通知を送る。デバッグ時は全件送る
  if(titleCount <= 2 || isDEBUG){
      await client.broadcast({
        type:"text",
        text:title+"\n"+titleURL
      });
  }
}

/**
  今日の日付をYYYYMMDDで返す
  */
function YYYYMMDD(){
  var dt = new Date();
  return dt.getFullYear()
             +(('00' + (dt.getMonth()+1)).slice(-2))
             +(('00' +  dt.getDate())    .slice(-2));
}

/**
  ボケてチェック処理を開始するよう登録する
  */
exports.boketeCheck = functions.runWith(runtimeOpts).region('asia-northeast1').pubsub.schedule('every 1 hours from 9:00 to 23:00').timeZone('Asia/Tokyo').onRun(async(context) => {

  console.log("ボケて配信処理を実行します");
  isDEBUG = false;

  const RATE = 30; //何分の1で発動するかの設定。 2日に1件程度の確率に設定
  var seed = Math.floor( Math.random() * RATE );

  if(seed == 0){ await boketeCheck(); }
});

/**
   ボケてのピックアップをチェックする
  */
async function boketeCheck(){

  const browser = await puppeteer.launch({ headless: true, args: ['--no-sandbox', '--disable-setuid-sandbox']});
  const page = await browser.newPage();

  await page.goto('https://bokete.jp/boke/pickup'); //記事を取得するためボケてへ接続

  console.log('接続しました:%s',await page.title());

  const bokeLists = await page.$$(".boke-text");  //ピックアップの全ボケ取得
  var idx = Math.floor( Math.random() * bokeLists.length ); //全ボケからランダムで1つ選ぶ

  const odaiHref = await bokeLists[idx].getProperty('href'); //記事タイトルタグのテキスト取得
  const hrefText = await odaiHref.jsonValue();

  //Line配信の準備 本番かデバッグモードかで配信先を切り替える
  const client = new line.Client((!isDEBUG ? config : config_DEBUG));

  //ボケをLineメッセージで全員へ配信
  console.log("Lineメッセージでボケを配信します:%s",hrefText);

  //ボケのLine配信
  await client.broadcast({
           type:"text",
           text:hrefText
  });

  return await browser.close();
}

湯ワーキングスペースBEST3【日帰り温泉編】をこっそり教えます #26

自宅作業でNew Normalな今日このごろ。
皆さんも様々な形でリモートワークしていることでしょう。

でも実は、「自宅」ってそんなに作業に向いている場所じゃないんです。

ときには、都心を離れて一人で集中作業したほうがいいときもあるんです。

どんな作業なら都心を離れたほうがいいのか?

それは、

製品企画
経営戦略
クリエイティブ

のような集中力&発想力が求められる作業。

本来こういう集中作業は自宅やオフィスなどの日常環境は向いてないのだ。

どんな環境が集中作業に向いているのか? 

それは「温泉」

日常を離れ思考をリラックスさせる「大自然」
裸になって一切の情報が遮断される「お風呂」

温泉こそが高いアウトプットを出せる「湯ワーキングスペース」なのだ。

もはや都心から日帰りできる範囲にある湯ワーキングスペースを複数知っていることは、現代のつよつよビジネスパーソンにとって必須であろう。

1時間も電車に揺られて時間がもったいないと思うかもしれないが、電車の中も思索に使おう。一切のしがらみを、怠惰な日常を、最寄り駅に捨てショートトリップへ旅立つのだ。

僕は大学卒業と同時に起業して、もう15年以上このスタイルで自由に働いてきた。オフィスや自宅も使いながら、ここぞ!という作業の時は温泉にでかけ、新製品や経営戦略などをねってきたのだ。(ちなみに自由が行き過ぎると逆にオフィスの貴重さにも気づく。それはまた別の機会に)

そんな僕が長年のリモートワークスタイルの中で発掘してきた、極上のオススメ湯ワーキングスペースのBEST3【日帰り温泉編】を、今回はこっそり教えることにしよう。

人が増えすぎると集中しにくいので本当は教えたくなかったが、コロナ不況で閉店されたらもっと困るので、やむなく発表に至った。

※ささやかな注意点がある。長時間いるのならお店側の利益にも配慮して、たっぷり食事や飲み物を注文すること。そして土日は人も多いし純粋に休みたい人の邪魔なので平日の日中がオススメ。

<選考基準>

①日帰り入浴可能な天然温泉
②都内から1時間程度で行ける
③静かで人が少なめ
④横になれる広い休憩スペース
⑤リモート会議ができる個室有
⑥PC作業&電源OK確認済

第三位 【熱海】日航亭・大湯

まず第3位はメジャーな温泉リゾート熱海から。熱海は東京から最も電車でアクセスしやすい有名温泉街。品川から新幹線で30分そこそこで到着できる。

そんななかで、地元民に愛されているのがこの大湯。かの家康公もよく通っていたというから大物系の湯ワーキングスペースなのである。

土日は観光客で賑わっておりあまり落ち着けないが、平日は地元のおじいちゃんたちがちらほらで静かな雰囲気だ。

建物も昭和のレトロな哀愁につつまれており、変に気張らずに田舎の実家に帰ってきた感じでくつろげる。

天下国家を左右する大きなプロジェクトは、家康も愛した大湯で決まりだ

Goodポイント:アクセスが良い、レトロな雰囲気
Badポイント:やや狭い、サウナが無い

日航亭・大湯の詳しい情報はこちらへ

第二位 【箱根】湯の里おかだ

第2位は「温泉の横綱」箱根から。

箱根には日帰り入浴できる素晴らしいスポットがたくさんがあるが、湯ワーキングに使うとなると快適なところは意外と少ない。

そんななかでも「おかだ」は全方位的に湯ワーキングニーズを満たしてくれる素晴らしいスポット。さらにサウナまで付いているなんてもはや奇跡。

電車で行くと箱根湯本駅から20分以上歩く必要があるが、レトロな温泉街を過ぎて小川のせせらぎの中、ささやかな山登りを満喫しよう。非日常はその奥に潜んでいるのだ。

箱根はいつも人で賑わっているが、おかだの平日の休憩スペースは静穏そのものだ。

静かな森林でデトックスワークをするなら温泉界のエースで4番、おかだで決まりだ

Goodポイント:全体的に品質高い、静かな環境
Badポイント:やや遠い、電源とWiFi弱い

第一位 【小山】思川温泉

満を持しての第1位は、小山にある思川温泉だ。え?なにそこ知らなかった?
そう、温泉としてはマイナーだからこそ静かで湯ワーキングスペースとしては王者なのだ。

関東圏の皆さんなら「おやーまゆーえんちー」というCMを見たことあるはず。残念ながら遊園地は2005年に閉園してしまったが、ここは跡地に出来た大人の遊園地。
おやま遊園地のCM

思川温泉のつよつよポイントは絶景。都内から1時間の場所に、まるで原始時代のような雄大に流れる川と広い温泉の開放感は、もはやアート。

さらに、ここはサウナも最高なのだ。アッツアツのサウナ室に、天然地下水+大きい釜水風呂の組み合わせ、そして大自然を見ながら開放的なリゾートチェアで「ととのう」贅沢。一歩間違うと日常に戻れなくなってしまうのだ。

(ちなみに、サウナは頭がボーッとしてしまうので作業中はあまりオススメしない。むしろ作業後のご褒美に活用しよう)

時空を超越し原始人となり、食堂の座敷か、絶景の個室を借りて集中作業しよう。

なにもかもから忘れ一つのことに思うとき、さあ思川を思い出せ。

Goodポイント:景色、サウナ、静か
Badポイント:休憩室のTV、個室高め

ということで、つよつよ湯ワーキングスペースBEST3をこっそり発表させていただいた。

ちなみに、湯ワーキング中は、スマホを脱衣ロッカーにしまっておこう。集中作業にとってスマホは敵でしかない。

作業に詰まったら裸になってボーッと景色を眺め、アイディアがでてきたら急いで服を来てMacへ猛ダッシュ。これを10回も繰り返す頃には、決して都心では創れない異次元のアウトプットが出来上がってるはずだ。

さあ、もう我慢も限界のはずだ。君も湯ワーキングライフをスタートさせよう☆

今回の記事が好評なら、【都内編】や【番外編】も発表しよう。

気になる方はつよつよ社長のtwitterのフォローよろしく。

もし、とっておきの湯ワーキングスペースを知ってる「つよつよ温泉ワーカー」いたら、ぜひこっそりDMで教えてほしい。

コロナ時代のNew Normal、すべての常識から開放されて、それぞれ個性あるNew Normalを実践されたし。

ババンババンバンバン!

ではでは

リーダーになるための5つの具体的TIPS #25

(社内の若手向けにブログを書いてくれと言われて月に1回程度書いている。誰かの参考になればと思ってこちらにもUP)

リーダーについて、いつかはやってみたい、でも向いてないかも
でもやってみようかな、の繰り返しでダラダラ時間が過ぎ去って行く人は多いです。

リーダーになりたくないっていう人もいますが、人はみなリーダーですよ。

あなたが休日にどこかに行くか決めるとき、あなたはあなたのリーダーです。
あなたが平日に嫌な仕事をやってるとしたら、上司があなたのリーダーです。
平日の仕事でも、せめて自分のリーダーぐらいは自分でやれるようになりましょう。

そのためにも、リーダー力を鍛えていきましょう。

でも、リーダーになるほどの力がない?

そう、

リーダーになればリーダー力は鍛えられる
でも、リーダー力がないから、いつまでもリーダーに任命されない

の典型的なデッドロックがよく起きているんです。

そこで、数多くのニューリーダーを育ててきた僕から見て、

具体的にどうすればリーダー力が鍛えられ抜擢されやすくなるのか?

5つの具体的アクションを伝授します。

<1>手を挙げる

はい、もう今日はコレで完結と言ってもいいぐらい大事なことです。

リーダーになりたいなら、面談や飲み会や発表会で口にしましょう。
それだけで打席は回ってきます。言えば伝わる。言わないと伝わらない。

静かに頑張ってればいつか誰かが推薦してくれるのかなと思ってる
人もいると思いますが、無いです。そんな奇跡を待つのはやめましょう。

僕は松本人志さんのファンでいつか一緒にアプリを作りたいと周囲に話していたら
人の紹介で本当に実現しました。偶然だけど必然。そんな経験を無数にしています。

手を挙げましょう。口に出しましょう。まずはそれからです。

<2>上司と飲みに行く

手を挙げたところであなたの視座が低いならリーダーにはなれません。

あなたが船に乗っている料理人としましょう。
船にはいろんな人が乗っています。

料理人
レストラン店長
航海士
船長
船会社の社長

この順番、どんな順番か分かりますか?
下に行くほど視座が高まり、見ている世界が広がっています。

実際に料理人から船会社の社長に上り詰めた人もいるでしょう。
そんな料理人が何をしていたか?

店長や航海士と飲みに行くんです。
そしたら、売上の話とか3ヶ月後のあるべき体制なんかの話が出てきます。

料理人同士で話すだけだと客や店長の愚痴で盛り上がるのが関の山。時間の無駄。
相手が偉すぎても話がチンプンかんぷんなので、ちょっと上ぐらいの上司がオススメ。

僕は学生バイトの時にその会社の部長と毎日飲みに行ってました。

なぜ部長はあの人をアサインしたのか。あの案件は今後どうなるのか
質問しまくってました。なんなら社長と同じ視座を持ちたいと思ってました。
その結果、社員より重要な仕事を任されるまでそう時間はかかりませんでした。

視座が低い社員は、いずれ視座が高いアルバイトの指示に従うことになるでしょう。
飲みが苦手な人は、ランチとか移動時間とかが狙い目です。

<3>GIVE&GIVE

いくらあなたが運良くリーダーになれたとしても、
実際のところ、あなたについていきたい人はいるでしょうか?

ちょっと不安ですよね?

どうすればあなたについていきたくなるのか。
答えはカンタンです。与えましょう。与えまくりましょう。

仕事は手伝ってあげ、技術は教えてあげ、手柄はくれてやれ、
困ってたら助けてやり、雑談は盛り上げてやりましょう。

そんなあなただったら、みんながついて行きたくなります。

bravesoftを創業する時、裸一貫、一人ぼっちになりました。
しかしすぐ、法政大学時代の仲間4人がいきなり入社してくれました。

たぶん、学生時代やアルバイト時代にギブをしまくった結果だと思います。
でも、下心があってギブってたわけじゃなくて、なんとなくです。

このブログも1つのギブです。書いて何のメリットが有るかよく分かってません。

でもいいじゃん。小銭ばっかり数えてないで、気前よくギブリましょうよ。
ギブって気持ち良いんです。

<4>売上を上げまくる

いくらあなたに人がついてきてチームが成立したとしても
勝てないリーダーについてきたい人はいません。

勘違いしないでください

優しいリーダーより、仲良いリーダーより、かっこいいリーダーより、賢いリーダより、、

「勝てるリーダー」にみんなついていきます。

売上はわかりやすい勝利です。

あなたがまだリーダーじゃないとしても、売上にこだわってください。
エンジニアでも売上にこだわることはできます。
営業が赤字案件とってきたとか顧客がバカだとか、言い訳してないで稼げるエンジニアになりましょう。

僕は学生バイト時代から売上にこだわってました。

開発成果がどのぐらい売上に貢献したか判断しにくい自社事業の場合なら、どんな機能をどれだけ開発したんだってことを自主的に可視化して、売上換算でこれぐらい開発しましたよ。
みたいに「価値を示す」ことにこだわる学生でした。

その後、起業したときには、わずか23歳のエンジニアに案件がどんどん集まってくれました。かつての同僚や顧客が案件を紹介してくれるんです。どんな状況でも「価値」と向き合う人には自然と良い仕事が集まってくるみたいです。

売上・成果・評判などなど、「価値」を示せる常勝リーダーを目指しましょう。

<5>心を磨く

もしあなたが勝てるリーダーになったとしても、あなたの性格が悪ければ、
いずれみんな去っていくか、盛大に裏切られるでしょう。僕のように。

心を磨くのは他人のためではなく、自分のためです。

社長にかかるストレスを説明する時には、

「旅行の幹事してたら、参加者は遅刻したり不満いう奴だらけ」

のときにかかるストレスの、だいたい100倍ぐらいですって説明してます。
リーダーとか幹事ってストレスかかるんです。どんどん性格が荒れてきます。

逆に言えば、社長とかリーダーがなぜ尊敬されるのかって考えたら、
そういうストレスに耐えてそれでも前を向ける器があるからですよね。
尊敬されるリーダーは相応の忍耐力をもっており、尊敬に値します。

心を磨くために、旅行の幹事を引き受けましょう。心を磨くんです。
あなたが幹事のストレスをいくら主張しても旅行は成功しませんよね。
理不尽に耐えながら、いかに楽しんでもらえるかだけを考えましょう。

「また一緒に旅行行きたいね」

そう言われることだけを考えましょう。
楽しい旅行を提供できるリーダーになったとき、自然と人は集まってきます。

僕はもともと性格が前向きでどこでも気に入られるタイプでしたが、
それでも20代は尖っていて部下に辛い気分にさせたことは結構あったと思ってます。

しかし、数々の裏切りや離反を体験した結果、

もっと心を磨かないと大成功は出来ないことを30歳あたりで理解しました。
成功をとるかワガママを通すか、一度考えてみることをオススメします。

・・・ということで、リーダーになるための具体的なTIPSをまとめてみました。

ようするに、

手を挙げて、上司と飲み、周囲を助け、売上を上げ、幹事をすれば

きっとあっという間にリーダーになれるでしょう。

もちろん、カンタンではないけど、難しくもないですよ。

やる気さえあれば誰でも立派なリーダーになることができるんです。

実はまだまだたくさんノウハウがあるけど、時間の都合でここまでにしておきます。

もっと詳しいノウハウを知りたければ、

つよつよchへのチャンネル登録をお願いします笑

「マネジメントが苦手」と思いこんでるエンジニアに言いたいこと #24

「ちゃんとマネジメントが出来るエンジニアって少ないよね」

これは日本人の共通見解だと思います。ホントに少ないんです。

「日本から新しいイノベーションが出てこないよね」ともよく言われます。
これも「マネジメントできるエンジニア不足問題」から来てます。

どういうことか?

いわゆるGAFAやSony、Hondaとか、大きな技術イノベーションを起こした会社って、

エンジニアが経営=マネジメントをしたパターンが大半なんです。

マネジメントって「チームで成功させる」っていう意味です。一人で出来ることに限界がある以上、

マネジメントできるエンジニア=成功できるエンジニア

っていうことなんです。

そこで今日は、なぜマネジメントできるエンジニアが少ないのか?

そしてその問題をどのように解決できるのか説明します。

最初に言っておきたいのは、全員がマネジメントできるようになる必要は全くありません。一匹狼やプレイヤーとして幸せになることはいくらでもできます。無理しないように。

でも、一流になるようなプレイヤーって実はマネジメントもできるんです。
マネジメント力も持ちつつプレイヤーに徹している人が一流プレイヤーには多いです。

とくに20代の成長期は、ぜひマネジメント力を身につけておきましょう。
身につけたあとでプレイヤーを演じてもいいんです。

マネジメント経験も無いのに「プレイヤーが好き」と言い切る人は人生の可能性を狭めています。

さてさて、それではまず、

「エンジニアはなぜマネジメントが苦手なのか?」

の理由を解明しましょう。

圧倒的な理由第1位は、

「心優しくて人に指示ができない。責任とか怖い。だから人の上に立ちたくない」

だからです。あなたもそうですよね?

(ちなみに理由第2位は「人の気持ちが分からない」です。これはまた別の機会に・・)

でも、安心してください。僕も昔そうでした。エンジニアは最初みんなそうなんです。
エンジニアって人見知りだし、神経質だし、臆病だし、内向的なんです。

言い換えるなら、内向的な人がエンジニアに向いてるんです。

で、問題はココからです。

内向的な人はマネジメントには向いていませんよね?
目的達成のためにはいろんな人に働きかける必要がありますから。

それでは、その逆の「外向的な人」はマネジメントに向いてるのか?

それがそうでもないんです。

感度が鈍い人、繊細さが無い人は、良いマネージャにはなれません。
いろんな人に働きかけられたとしても、その内容がイケてないからです。

ちょっと混乱してきましたか?

よし、「ウサギ」と「カメ」で例えましょう。

ウサギは耳とか目とか繊細です。高感度センサーがついてていろんなことに気付きます。
ちょっとしたそよ風にもビクンビクン反応し逃げ出します。

カメはだいぶ鈍感です。見えている世界は狭くいろんなことに気付きません。
滅多なことでは動揺しないけど、ピンチに気づかず敵にひっくり返されます。

これ、どっちがマネジメントに向いてると思いますか?

・・まあ、どっちも向いてないか(笑)

質問を変えましょう。どっちが良いマネージャーになる可能性が高いでしょう?

ウサギだと思いませんか?
高感度センサーを持ったウサギが、動じない精神力を手に入れた時、つよつよマネージャーになるんです。

内向的な人が外向的を身につけたほうがつよつよになるという研究結果は、メンタリストdaigoさんも説明しているので、詳しく知りたい人はこちらをチェック

内向的人間はすばらしい 脳科学で明かされた「リア充ざまぁ」な事実

エンジニア出身でマネージャとして活躍している人は、もともとは内向的なのに、ガンバって精神を鍛えた人だということを説明しました。

では、精神を鍛えるにはどうすればいいでしょう?

それも簡単です。慣れるだけです。

面接を思い出してください。
まあ人生最初の面接から1〜100回目の面接ぐらいまでは緊張しますかね。

でも、101回の面接はどうですか?もう結構なれてませんか?

なんのことはない、「習う」は「慣れる」ってことなんです。
マネジメントも同じことです。向いてないと思い込んで諦めて挑戦していないだけなんです。

・・それと皆さん、「優しいから向いてない」っていうゴマカシもやめましょう。

本当は優しいっていうのも違いますよね?本当は怖いだけなんです。
うまく出来ないマネージャのテンプレは、「頼りない父さん」パターンです。

子供が悪いことをしても叱れない。ちょっと良いことがあると過剰に褒める。何か失敗しても、環境が悪い、教育が悪い、国家が悪い、父さんが悪いといって本人に責任を軽くする。

これって優しいお父さんでしょうか?違いますよね?どっちかというと頼りないですよね?

厳しくても結果的に子供を成長させるお父さんが本当に良いお父さんですよね?

本当は嫌われるのが怖いだけなんです。心のセンサーが敏感すぎて少し悪口言われただけでも大打撃なんです。
優しいっていう言い訳はもう禁止にしましょう。言うべきことを言える人になりましょう。

「エンジニアには2種類しかいない。逃げる人か、挑む人か。挑む人だけがマネジメント力を持つ」

エンジニアの皆さん、「嫌われる勇気」を持ちましょう。

これまでも我々エンジニアの勇気がイノベーションを生み、社会を進歩させてきたんです。
ムッキムキのマッチョウサギに育ち、つよつよエンジニアとしてチームを勝利に導きましょう。

表面だけ優しい上司はただの臆病者です。
勝てる上司、厳しくても育てる上司が本当に優しい上司です。

ここまで読んで、少しずつマネジメントに興味が湧いてきたエンジニアもいるかと思います。

そういう人の背中を押す動画を収録しましたので、見てみてくださいね。

「マネジメントが苦手」と思いこんでるエンジニアに言いたいこと

ではでは!

コインハイブ(Coinhive)事件の意見書を公開します #23

最近ハッカーRADIOというラジオ番組をはじめたり、Youtubeつよつよちゃんねるを開設したりと「外交」を増やして良かったことはいろんな情報が入ってくることだ。

コインハイブ事件については、「へ〜なんかビットコインで悪い人が捕まったんだ」ぐらいに捉えていたが、一緒にラジオをやっている池澤あやか氏が怒っているという情報を聞き、Youtube番組内(今さら聞けない! 池澤あやかに聞く「コインハイブって何が問題?」)で詳しく聞いてみたところ、「たしかに警察やりすぎ」という印象。さらに一個人でしっかりと意見表明する池澤氏に勇気をもらったので、ここにブログ投稿&意見書も公開・提出する。

事件のカンタン説明

サイトに来た人のPCを無断利用して仮想通貨を稼げるのがコインハイブ

といってもそんなにたくさんは稼げない。数千円レベル

そんなにPCも重くならない。(重くなったら二度と見ないし)

なのに警告もなく「いきなり逮捕」された人が22名

うち1人のモロ氏が簡易裁判の判決を拒否し地裁へ

地裁は無罪獲得。高裁は逆転有罪。舞台は最高裁へ

ハッカー協会が4/1まで意見書を募集。まとめて最高裁に送る 
↑イマココ

ズバリ結論

捕まった人は確かにちょっと悪い

でも、逮捕するレベルのものではない

Apple審査やgoogleのブラウザ改善など、民間レベルで対処されるもの

これで逮捕ならエンジニアの萎縮と技術衰退の恐れあり

そして警察や裁判所への嘲笑をまねく恐れあり

<意見書>

裁判関係者の皆さま、お忙しいところに本文をご覧頂き誠に有難うございます。

僕はエンジニア社長としてbravesoft社の経営を行っており、この判決の影響を受けるものです。

bravesoftは創業15年で150名、アプリやWEBサービスの開発を行っております。

これまで開発したものは首相官邸やアメリカ大使館、東京大学やJST、NICTなど公共団体のものからTVerやボケて、ベネッセ様や東京ゲームショウなど民間のものまで1000件以上に及びます。

コインハイブはたしかに技術のよろしくない活用例だとは思いますが、今回の判決で有罪にしてしまうことは大変危険なことです。

「この程度のこと」で逮捕されるとなると、エンジニアとしてはリスクを避けるために多くの開発案件を拒否せざるを得ません。

コインハイブで受ける閲覧者の不都合(PCが重くなる)は軽微な不都合です。そしてPCが重くなるサイトは自然と使われなくなります。

こういった利用者に不都合をあたえる技術はgoogleなどのブラウザ開発者やまとめサイトなどから支持されないために、自然と淘汰される事例をこれまで何度も目撃してきました。

「この程度のこと」というレベルを技術がわからない方に分かりやすく伝えるなら、テレビ番組が広告と明示せずにスポンサーの商品を紹介したら逮捕されるようなものです。どこまでが広告でどこまでが本当なのか?スポンサーのイチオシの服をドラマのヒロインが着ることは悪なのか? その線引は曖昧故に、今回有罪になると今後我々エンジニアは多くの開発を拒否せざるを得なくなるのです。良くない番組は自然と淘汰されます。そこに警察や司法は必要ありません。

視聴の対価を払う方法には、広告、課金、そして今回のようにPCリソースの提供などいろいろあります。それぞれの対価を払う方法でサイトを見ている時点で、「意図」に沿っていると言えると思います。嫌ならもう2度と見なければいいだけです。

それを明示するのか明示せずに行うのか、もちろん明示したほうが良いと思いますが明示しなくても「逮捕されるほどの不正」では無いと思います。

創業当初より「技術で社会に貢献する」という思いで真摯に開発に励んでまいりました。社会にもそういう真っ当なエンジニアが大多数で、悪い人が長期的に反映することもありません。

技術革新は常に「なんか怪しい、大丈夫かこれ?」から始まります。

例えば、

LINEは勝手に友達の電話番号を友達候補リストに表示しています。
スマートニュースは勝手に新聞の記事をコピーし表示しています。

どちらも僕は「これ大丈夫か?」と思いましたが、一般に受け入れられて、多くの人を幸福にしました。大丈夫かどうかは民間が自主的に判定し、ダメなものは民間が排除するメカニズムがあります。今回程度の行為が法的にダメという判例ができれば、多くの新規プロジェクトがリスクとなり開発そのものが開始されません。

いま、今後の日本の技術革新に大きな足枷となる判例が生まれることを恐れています。

そしてもっと恐れるべきは、警察や裁判所へのエンジニアや国民の不信感や嘲笑です。

日本人はなんだかんだいって警察や裁判所を信頼しています。

逮捕される人は悪い人だと信じています。

刑罰そのものよりも「逮捕」という風評により生きづらくなるのを恐れています。

この警察や裁判所への信頼・逮捕への恐怖こそが世界No.1の平和的国家を実現しています。

僕が「コインハイブで悪い人が捕まったんだな」と当初感じたのは警察を信用してたからです。

この判決により「逮捕された人はそんなに悪くないかもね」「むしろ勇者でカッコイイじゃん」

に人々の意識が変わることを恐れています。

我々が警察や裁判所を信頼しているように、ぜひ皆さまも我々民間を信頼してください。

コインハイブはよろしくない技術だと思いますが、法的には無罪で良いと思います。

少しでも参考になれば幸いです。
よろしくお願いいたします。

最強エンジニア社長 & つよつよチャンネル。始動 #22

23歳でbravesoftを起業したその日から、ひたすら営業と開発に走り回ってきた。

「最強のものづくり集団となり、挑戦が溢れる新時代を創る」

ただそれだけのために上を目指し続け、たぶん毎月300時間以上の稼働を下回ったことはない。

気づけばもう15年が経過して、会社もそれなりの規模になってきた。

営業も開発も信頼できる仲間が集まり、社長らしい仕事をやるフェーズ。

それは、広報。

bravesoftを広く知ってもらい、参加したい!取引したい!という同士を集める活動だ。

もっと言えば、これは会社を作った目的でもあるけど、エンジニアの声を社会に届けたい。

日本におけるエンジニアの立ち位置は異常だ。

エンジニアはどこかで、一般社会に距離を置かれている。

エンジニアがたくさんいる会社。受託・SI・SESの会社も、どこかで距離を置かれている。あるいは下に見られている。地味な存在で、言ったことをやってくれるだけの存在。受注までは何でも言うことを聞き、受注後は何ら融通が利かない(まあこれは他の業界にもあるか笑)、あまり友達になれない人たち。。

エンジニアはスーツを着てブツブツ言いながらスケジュールの帳尻を合わせるキツイ仕事。

日本ではエンジニアはそんな存在だ。

海外では違う。AppleもAmazonもfacebookもGoogleも創業者はみんなエンジニアだった。世界中のエンジニア達が社会を先頭で動かしているのだ。

もう皆忘れてしまったようだけど、日本はかつて技術立国だった。戦後の焼け野原から、たった40年で世界一になるまでに引き上げたのはソニーホンダを始めとする技術者の情熱だった。

エンジニアを最新ガジェットとRedBullを与えられて喜んでるだけの「ただのいいヤツ」で終わせてはいけない。社会のあり方そのものをアップデートする主体的役割を果たしていくべきだ。そして日本を挑戦できる国に再び変えていくべきだ。

愚痴を言ってても仕方ない。自分がやるしかない。

1.卑屈になっているエンジニアに誇りを持たせたい。
2.少しでもエンジニアの声を集めて社会に届けたい。

そう、僕の仕事はここまで来たらもはや「すべてのエンジニアの広報」なのだ笑。

まずは2020年2月からラジオ日本様で目指せ!ハッカーRADIOというラジオ番組を始動。有名なエンジニアにインタビューをしながらエンジニアの声を業界内外に届けることを始めた。かみまくりのぎこちない進行でお恥ずかしいが、とにかく始めた。どこまで行けるか全く分からないが、とにかく動き出した。

そして最近、次の一手として最強エンジニア社長というキャッチコピーでTwitterを始めた笑。

アカウントだけなら取得したのは結構前の2008年。あのころのTwitterは荒れていた。

実はかつて、Twitterで炎上騒動を起こしたことがある。

虚構新聞というアプリでただAppleの仕様通りに個人IDを使っていたことが、プライバシーを不正に取得していると言われてしまったのだ。

(ググってみたら当時に書いた謝罪文を発見。30歳になりたての自分、結構頑張ってる笑)

そんなこともありすっかりTwitter離れしていたが、この際、Twitterも始めてみることにした。

そして最近は空前のYoutubeブームである。芸人引退をかけてジャージ姿でYoutuberになったカジサックさんには多くの人が勇気づけられた。

ということで、さらにさらにYoutuberも始めることにした。

YoutubeもTwitterもまだまだ素人レベルなので、元ブレイバー(bravesoftでは元社員をこう呼ぶ)でテンガマンという全世界が呆れる訳のわからない動画を作った孫さんにも手伝ってもらうことにした。(それでも200万再生!)

<とある土曜日の朝方>

孫さんから電話「どんなチャンネルにするか打ち合わせしましょう!」

菅澤「オッケーじゃあとりあえずドライブ行こうか」

孫さん「行きましょう!」

運転しながら孫さん「どこ行きます?」

菅澤「うーん伊豆とか?」

孫さん「そっち方面渋滞みたいす。川越あたりに落ち着きません?」

菅澤「え〜そしたら富士山で手を打たない?」

孫さん「・・まあとりあえず行ってみますか。そういえば最近ドローン買ったんですよ」

菅澤「いいね!そしたら動画取ろうよ」

・・結局、1泊2日でドライブしながらの打ち合わせ10時間&富士山絶景で撮影10時間というバタバタしつつも楽しいYoutube合宿となったのだった。

帰りの孫さん「いい絵が取れましたね〜。ところでチャンネル名どうします?」

菅澤「うーん、最強を目指すし、最近つよつよって言葉楽しくない?」

孫さん「じゃあそれにしましょう!」

チャンネル名は「つよつよちゃんねる」に決定。アイコンはもちろん富士山だ。

こういう感じで、生涯をかけた楽しい挑戦のドライブを、15年間続けているのです。

つよつよちゃんねるの登録はこちらっ!

20代で人生は変わる。30代では変わらない #21

いきなり手紙が届いた。三宅島の小学校の松本くんからだ。
エンジニアになりたくて、エンジニアの人に会ってみたいとのこと。

こういうのにbravesoftは弱い。半ば強制的に社長の僕が出かけることになった。

その授業はドリームプロジェクトといって、小学生が自分で憧れの人を呼んで授業をしてもらうというひとりの熱血先生の情熱から始まった素敵なプロジェクトだ。

三宅島では先生と飲みながら島の生活を聞いたり、小学生とボケてで盛り上がったり、歌を歌ってもらったりと楽しく過ごした。その時スピーチした内容が、全国の小学生にも伝わればいいなと思ったので、ここにアップする。

———

ここに来る前、みんなは小学生に話が通じるの?なんて心配してましたが、僕は小学6年はもう大人だと思ってるので、大人が大人に向けて大人の話をします。

皆さんはきっと不安だと思います。島に生まれて将来都会でやっていけるのか?とかね。

でも、僕はむしろラッキーだと思います。この島には大自然があり温かい人間社会があります。

ところで、iPhoneを作ったのは誰か知ってますか? そうジョブズです。

ジョブズは子供にiPhoneを持たせませんでした。自然や人間から学ぶのが大事だからです。

ちなみに、子供はなぜ遊ぶのか知ってますか?なんで遊びたくなるようにできてるのか?

それは学ぶためです。

子供は遊ぶことで、たとえば水の怖さだったり、火の起こし方だったり、人を傷付ける愚かさだったり、いろんなことを学びます。

なので、思いっきり毎日遊んでください。家でゲームをやるのもいいけど、ほどほどにして自然の中で思いっきり遊んでください。

都会の子は自然がないのでゲームばっかりやってます。でも、ゲームをいくらやっても新しいゲームは創れません。カレーばっかり食べてもカレーやお米や野菜が創れるようにはなりません。

答えは自然のなかにあります。みなさんは答えに囲まれて生きています。チャンスです。ゲームもカレーも自分で作ったほうが楽しいよ。

皆さんは今日、僕の事を見てこう思ったと思います。「楽しそうな人だな」と。

あまりこういう事を言う大人は普段いないかもしれないけど、僕は毎日超楽しいです。つらいこともたくさんあるけどそれも含めて楽しいです。

だから、皆さんにも毎日楽しいと思って生きる人になってほしいです。

じゃあ今から何をしたらいいか? 夢を持ったほうがいいか? どうすれば成功できるか?

さっきそういった質問もあったけど、

なにも考えなくていいです。

20代で社会に出てからが本番です。それまでは適当に勉強とかゲームとか遊んでてください。

そして、20代は本当に重要です。

楽しく生きるためには、20代で何かに打ち込んだり、つくったり、たくさんの挑戦をして、たしかな自分を手に入れる必要があります。

20代からの社会の広さを思えば、10代の頃の悩みなんてなんでもないです。

例えば君の友達で今まで1回も家から出たことなくてウジウジ悩んでる人がいたら、

「とりあえず外に出て学校こいよ」

って思うよね?

社会に出るのはそれ以上のことです。だから考えるのは社会に出てからでいいです。

それで、もし20代でなんにもせずダラダラ過ごしてしまった場合、

30代になってから何かをしようとしても無理です。30代になったら人はあんまり変わりません。

だから20代のときに、本気を出してください。それまでの10代は何もしなくていいです。

今日、一番伝えたかったのはこのことです。

さいごに、1つだけ人生が良くなるコツを教えるとしたら、「手を挙げる」ことです。

これなら今日からでもできるよね?

今回は松本くんが手を挙げたことで僕はここまできました。この場を一番楽しんでいるのは松本くんであり、そして大きな自信にもなり、少しだけ人生が変わったと思います。

まっさきに手を挙げる人には良いことしか起こりません。

来月皆さんは小学校を卒業するらしいですね。

ウジウジしてなんにもしない人生を卒業して、少しでいいので勇気を出して、

手を挙げる人になってください。

それでは終わります。

がんばってね!

——-

東京に戻ったら、たくさんの手紙が届いた。そのうちのアツい一通をシェアして終わろう。

きたぞ未来!世界中で大流行の電動キックボード(スクーター)を体験レビュー&解説 #20

10年前から現代にタイムスリップしたなら、全員がスマホを見てる景色に驚愕するだろう。

ごくたまに、1つのイノベーションが街の景色を一変させることがある。

2020年1月。3年ぶりにロサンゼルスに来たらところ、まさに街の景色が一変していた。

電動キックボードだ。街中に電動キックボードが放置されているのだ。

さっそく試したところ、これがまあ快適!

なんと3日間で18回も乗ってしまった。これはきっと近い将来に世界中の景色を変えていくはず。モビリティ革命といえば自動運転やドローンも華やかでおもしろいが、身近で起きている確かなイノベーションにもしっかり注目しておきたい。

2年前からブームになってるようですでに日本語の記事もいろいろとあるが、普段からサービス開発をしている現場の目線から、最強にわかりやすくレビューしよう。

電動キックボードって?

・電気で動くキックボード
・街中に雑に置かれている
・車道の自転車レーンを走る
・どこで乗り捨ててもOK
・時速25km (かなり速い自転車)
・300円ぐらい(乗車100円+20円/分とか)
・スマホアプリから利用
・スタッフが巡回して充電
・運転は自転車よりカンタン
・自動車免許必要(日本のでOK,免許いる?)

きっかけはGoogleマップ

最初に知ったきっかけはGoogleマップ。アプリの言う通りにぼーっと歩いていたらいきなり電動キックボード出現&乗り換えるように指示された!笑

LAは地下鉄が少なくて、電動キックボードを使ったほうが速いシーンが多々ある。

どうやって使うの?

①アプリDL&初期登録

主に3つの会社(Lyft,Lime,BIRD)から選べる。

今回はLyftを選択した。Lyftアプリがあれば新規DL不要。
(LyftはUBERと同様の配車サービスで、米国ではこの2つが競い合っている)

SMSやクレカ+免許証(日本の免許でOK)のアップロードなどよくある流れで2分で完了。

キックボードに乗る

近くのキックボードの予約ボタンを押すか、QRをスキャンすれば即利用開始。はやい!

キックボードを降りる

降りるボタンを押して、キックボードの写真を撮影で終了。カンタン!

どんなときに便利?

数キロレベルの移動に便利。

たとえば、

渋谷→原宿
都庁→歌舞伎町
上野→秋葉原

割と近いのに、徒歩や電車だと30分ぐらいかかってしまう移動が10分足らずで到着できる。
特に重い荷物を持ってる観光客や買い物客はとっても助かる。


バッグが重かった(20kgぐらい)ので助かった

イケてるところ

・乗ってるだけで楽しい
・見た目もクール
・こがなくていいから疲れない
・自転車より転びにくい
・押して歩くのも自転車より楽
・立って乗るので乗降がスムーズ

電動キックボードを知ってしまうと、わざわざ自転車に座って「こぐ」というのが面倒に思えてしまう。さらに重心が高いためぐらつくて危険。そして必死にこぐ姿がスマートではないと思う人もいそうだ。

港区のシェアサイクルも想ったより伸び悩んでいる??

他の移動手段との比較

他の移動手段と比較してみた。

「タクシーみたいに疲れず自由に動け」て「まあまあ安い」というポジションを狙っている。

もちろん近くにキックボードが置いてないとダメだが、実際は観光客が行くようなところ(東京でいう山手線周辺)では大丈夫だった。

どんな会社がやってるの?

主なプレイヤーは4社。先行2社と、類似事業の大手2社(UBER,Lyft)が後発。

戦いはまだ始まったばかり。各地で大激戦が繰り広げられている。LAにも各社のキックボードでごった返していた。

個人的にオススメはLyft。また勝手な予想だが、最後に勝つのはLyftじゃないかと思ってる。

理由1:割とシンプルなモデルで差別化が起きにくく、規模の経済で大手が勝ちやすそう
理由2:Uberはセクハラ問題とかでバタバタで印象も悪い。Lyftは追い上げており勢いある
理由3:Lyftのセンス良いクリエイティブがキックボード的なファッション性と合いそう

実際、LAではLyftのキックボードが一番多かったように思う。

夏に大ヒット→急落!?

つい最近の2020年1月、電動キックボードにとって不吉なニュースが流れた。

電動キックボードのLimeが12都市から撤退し約100人を解雇

え?? 絶好調じゃなかったの??

考察のため、、Second Measure社による各社の売上推計を見てみる。

このグラフを見る限り、2つの理由が推察できる。

①冬は寒いのであんまり乗らない
②各社の参戦で観光客の争奪戦が激化

去年の各社経営会議はこんな感じかも・・

8月「すごい伸びてる!この市場は凄いことになるぞ。投資倍増、大量採用だ!」
12月「すごい落ちてる!寒いとこんなに落ちるの?? 人を減らせ〜!」

新興市場の行く末は誰にもわからない。経営はジェットコースターだ。

市場としては今はまだ黎明期で

観光客
クールに生きたい若者

のニーズを熱狂的に支持されはじめてる。

ただ、それだけだとそんなに大きな市場でもない。(小さくもないけど)

より大きく狙うには地元の普通の人達に使ってもらう必要があるが、iPhone普及に10年もかかったように、習慣はそんなに早くは変わらない。しかし着実に変わっていくとも思うので、しっかりマーケ&バージョンアップを続けたところが勝者になるのだろう。

日本でも流行る?

間違いなく流行る。港区は混雑してて道も狭いので、まずは吉祥寺とか、横浜あたりがいいかも。また地方都市では確実に需要がある(特に観光客)。そして港区でもダメかというと、自転車はバンバン走ってたりするので、港区でもきっとイケるはず。(ただ地下鉄やバスが充実してるのでそこまで強い需要でもない)

じゃあなぜ今の日本に無いのか。規制のためだ。

日本では電動キックボードが「原付」とみなされ、ウィンカーやヘルメットが無いからNGと。。

「原動機(エンジン)が付いた自転車」と書いて原付。

でもこれ、自転車に見える??

電動自転車はOKだけど、
電動キックボードはNG

電動キックボードは公共の利益にもなると思う。

・自転車よりも安全
・インバウンドや観光客に優しい
・自動車やバスを減らし環境に優しい
・高齢者に優しい
・高齢者の自動車事故を減らせる
・渋滞を減らせる

実は原付バイク普及の立役者はかの本田宗一郎。戦後で物資がないなか、自転車に外付けエンジンをつけて、やっつけバイクをつくったのだった。そしてそれが本格的なバイクに進化してHONDAが世界中を席巻。それはまだ日本全体がベンチャーだった時代の話。自転車にエンジンつけちゃおうぜぐらいの若いノリがあった時代。そのチャレンジスピリッツこそが今の日本に足りないものだ・・。

ということで、これを見るかも知れない偉い方・・。

一緒に動きましょう!

体験してみよう!

どうしても国内で電動スクーターにのりたい場合、挑戦する都市「福岡市」がちょくちょく実証実験をやっているので随時チェックしてみよう。

でもやっぱり、ただ乗るだけでなく、日常がいかに変わるかを実際に体験してほしいところ。

その場合には海外に行くしかない。

そこで2020年2月時点で電動キックボード(Lime)に乗れる国を列記した。(最新情報はこちらから)

アメリカ
アルゼンチン
オーストラリア
オーストリア
ベルギー
ブラジル
ブルガリア
カナダ
チリ
コロンビア
チェコ
デンマーク
フィンランド
フランス
ドイツ
ギリシャ
ハンガリー
イスラエル
イタリア
メキシコ
ニュージーランド
ノルウェイ
ポーランド
ポルトガル
ルーマニア
シンガポール
韓国
スペイン
スウェーデン
スイス
UAE
イギリス
ウルグアイ

10年後、街の移動手段が自動運転バスと電動キックボードだけになった未来を想像してみよう。
もし想像がつかないのなら、まずはロサンゼルスを訪れてみるべし。

世界中が感染した時、最後に生き残るのは日本人 #19

武漢発の新型コロナウィルスに世界が注目している。

中国に子会社を持つ我が社にとっては他人事ではなく、実際に社員の知人が感染したなどといった声も聞く。沈静化の目処が立ってない現状では細心の注意を払い準備するのは当然のこと。

こんなときふと思い出すのは2009年の新型インフルエンザの大流行。「パンデミック」という言葉が一躍有名になり、日本中がパニックになった。

連日のようにどこどこで発症、死亡者が出たといったニュースで溢れ、山手線の中でもマスクをしていない人はいないほど。

あれは2009年のGWのあたりで、発症源は北米だった。

・・・そしてちょうどその時、僕は友達と人生初の渡米を予定していたのだった。

前回のグランドキャニオンで死にかけた話しかり、どうもUSAにいくと何かが起きる。

もちろん、キャンセルすることも考えたが、まだまだ若くてお金も地位もない20代。もったいないしやっぱり行こうという話となり、不安を覚えながらも、成田空港へ向かった。

成田空港に人影は少なく、報道カメラが旅行者にインタビューしたりしていた。我々2人はしっかりとマスクを買い込み、戦地にでも向かうようにニューヨークへ旅立った。

ところが・・

ニューヨークに着いてみたら、マスクをしている人が全然いないのだ。

それどころかあろうことか・・

なんと我々がマスクをして歩いていると、道行く人が「おいチキン!」とか「ゴホゴホ」とか言ってからかってくるのだった。どうやら向こうではマスクをするというのはよっぽどのことらしく(あるいは逆に不安にさせるんじゃねーよバカ!みたいな感じか)変に目立ってしまったのだ。今の東京で、ガスマスクをして歩いたら同じ目に合うかもしれない。

何度かの屈辱のあと、我々はマスクを投げ捨てた。

それから5日ぐらいニューヨークをブラブラしただろうか。インフルのことはすっかり忘れマンハッタンを満喫し、あっという間に成田に帰ってきた。そうするとまた現実が出迎える。戦場は日本だった。輪をかけて日本中はマスクだらけ。文化の違いとはこういうことかと実感した。

テレビでは毎日のようにどこどこでインフルで死亡者がというニュースで大騒ぎ。今と違い誰もがテレビでニュースを見てた。発症者がでた高校に非難が集まり、マスクは飛ぶように売れた。

エンジニアの仕事は何でも疑ってみることだ。

僕は国内で1年にインフルで死亡する数を調べてみた。毎年だいたい1万人もインフルで死亡してしまうらしい。そうすると毎日のように数名は死者がでる。ニュースで言われているインフル死亡者も1日数名。あれ、いつもとペースそんな変わってない・・?

最終的に、新型インフルによって国内で死亡したのは200人だった。

1ヶ月後、厚労省は「新型インフルの死亡リスクはいつものインフルとそう変わらない」と発表した。つまりいつも流行ってるようなインフルに名前がついただけで、あそこまで騒ぐのはまさに取り越し苦労というやつだったのだ。

・・ああ!これだから日本人は心配性で数え切れないほどの保険に入り、無数の鍵を持ち歩き、占いに大金を費やし、大量のトイレットペーパーを買い占めるのか。

心配だらけでいっこうにハッピーになれないんだ!

と一瞬思ったが、ちょっと考えて思い直した。

「なんだかんだで最後まで生き残るのは日本人だな」

なにしろあのときはどうなるかわからなかったのだ。新型インフルが本当にやばいウィルスだったとしたら、ニューヨークの彼らは全滅だろう。結果論でいえば大丈夫だっただけで、本当にヤバいやつだったら、日本人だけが生き残った可能性もあっただろう。

検証までに、wikipediaによる2009年新型インフルの国別発症数の推移データをもとに100万人あたりの感染者数のグラフをつくってみた。(あくまで概算)

そう、日本人は圧倒的に感染率が低く、世界最強のウィルス防御力を誇っているのだ。

この争いばかりの世界が仲良くなれるとしたら、宇宙人が攻めてきたときだ。共通の敵がいると人は仲良くできる。新型ウィルスはまさに宇宙人のような存在。得体が知れない人類滅亡の危機を前に世界は一つになれるかもしれない。昨日も日本が中国に大量のマスクを送って中国人が15万いいね!をつけた、なんてニュースが流れていた。

日本人という民族は常に災害の危機にさらされてきた。

知ってるだろうか?

この地球上で、全人類の1.2%しか日本に住んでいないのに、大地震の20%は日本で発生する。そのうえ台風も洪水も大雪も飢餓も火事も頻繁に襲ってくる。もともと地球でイチバン危ない土地で、日本人は助け合いながら協調して乗り越えてきたのだ。1500年前に初めて作られた法律の一番最初には「和をもって尊しとなす」と書かれたのだ。

政治の「治」という字は川に台をつくると書く。助け合って洪水を防ぐところから共同体が生まれ、国となり、人類は栄えてきた。新グローバル時代に災害を機としてとらえ人類が和をもって助け合うべくリーダーシップを取るべき国は日本なのかもしれない。

政府だけの問題じゃない。

政府が電車を止めたり外出を禁止したり、入国を拒否するのもたしかに少しは有効かもしれない。しかし誰とも合わずに生きていける人はいない。

それ以上に一人ひとりの意識を変え、行動を変え、習慣化させることの効果は絶大だ。

日本が世界に教えられることはたくさんありそうだ。

・手を洗いましょう
・アルコールで消毒しましょう
・マスクをしましょう
・水回りキレイにしましょう
・料理にはしっかり火を通そう
・風邪気味なら無理しないで
・家では靴を脱ごうよ

我々からすれば当たり前の話ばかりだが、海外を旅する人は世界がいかに不潔か知っている。パリは犬のフンでまみれ、ニューヨークの地下鉄は下水道のようだ。

日本には世界一キレイで健康的で、助け合える人々が過ごしている。

政府に文句をつけて仕事をしたような気持ちになることもできるけど、あなたにできることはないだろうか?

【おまけ】グランドキャニオンで意思決定トレーニング #18

「人生とは旅であり、旅とは人生である」

これは中田英寿選手引退メッセージのタイトルで、いつも心の底に流れている言葉。

この記事はグランドキャニオンの1500m崖下で死にかけた話のおまけだ。

前回は、わかりやすく伝えるためにストーリ調で書いたが、その裏で普段から実践しているのが意思決定トレーニングだ。記事の内容はもちろんすべて事実だけど、実際のところ不用意なミスで危険な目にあったというよりは、わざわざ自分を追い込んでいたのだ。

人生も旅も、本質は先の見えない冒険だ。予想外のことも起きるし、ギリギリの意思決定が求められる。なので僕は、旅に学び、成長につなげるべく意思決定トレーニングを実践している

意思決定トレーニングについて

旅の最中はわざとこのようなハプニングが起きる環境に身を置くようにしている。いい感じにバスが来たら乗ってしまうし、良さそうな川を見つけたら、とりあえず向かってしまうのだ。ハプニングに出会うために。

一人旅においては、情報は足りなくて、協力者もいない。基本的に無力だ。その中でどのように生き抜くか、どうすれば目的を達成するか、極限な状況に追い込まれても冷静で正しい意思決定をできる判断力を育てていくのだ。

人生や経営は旅以上に未知な冒険である。進学・転職・結婚、起業や重要案件など、意思決定を前には誰もが不安で、情報やリソースは常に不足している。

そんな中、いかにベストな意思決定をするか?

旅はそんな未知の状況で意思決定し、結果を反省し成長に活かせる格好のトレーニング場だ。

川を目指すという意思決定

今回、大きな分岐点は、「⑤聖なる川」でルートの間違いに気づくも、川を目指すという意思決定をしたときだ。その前もバスに飛び乗ってみたりはしているが、この時まではただの気楽な観光だった。限られた情報のなか、それでも川を見に行きたい。そう思う人は1%もいないかもしれないが、子供のような本能的な気持ちに素直に乗っかる人もいる。僕みたいな人だ。そしてトレーニングのためでもある。

ある種の変態かもしれないが、挑戦に成功する人は、きっと川を目指すタイプの人だ。

僕の経験でいうなら、

①大学を卒業時、不安もあったが就職せず起業の道へ。
→地獄を見たが、結果的に15年成長を続けている。

②iPhoneが不発だと言われる中、未来を感じていち早く開発に投資。
→必死に実績を重ね「アプリ開発実績」でgoogle検索日本一位を獲った。

③オフィスが手狭になり、倍以上で月額350万の新オフィスに思い切って移転。
→経営危機に陥るも、3年後にはそのオフィスも手狭に。

などの意思決定をしてきた。

結果論で見ると楽観的ノリで飛び込んだだけと見えるが、現実は人生を賭けて多額の借金を負い先の見えない不安で夜も寝れない中での、ギリギリの意思決定ばかりだ。

孫正義は、「7割の成功率が予見できれば事業はやるべき。5割では低すぎ、9割では高すぎる」といっている。ビジネスの世界では、情報が完全に揃って状況が見通せるようになってからでは遅すぎる。イケるかも、面白そうでいち早く飛び込んだ人達が、その場その場でなんとか乗り越えいち早く成功し、皆が気づいた頃にはすでに次の挑戦に向かっていくのだ。

ふらっと立ち寄ったグランドキャニオンでは、現地の人に比べて圧倒的に情報量が限られていた。情報が限られており、リスクがあるなかで、ギリギリの意思決定を下すという経験は、なかなか普段から経験できない。情報がないグランドキャニオンで目標を達成するためには、質とスピードを兼ね備えた直感的な意思決定が必要になる。

冷静と情熱のあいだ

勇気と無謀は違う。ただノリだけでどこにでも飛び込んでいく人はすぐに命を落とす。

実際に⑤で川を目指すのを決めるときには

・地図で見る限り、帰ってこれる距離
・自分の登山実績からも大丈夫という判断
・地図を見る限り観光用に整備されていそうな道
・周りにちらほらと登山客もいる
・他の人が重装備でいける道なら軽装の自分なら大丈夫だろう

ということを判断材料に意思決定をした。

もし今回のケースで命を落とす人がいるとしたら、

「⑥迫る危機」で3時間下ったあたりで怖くなり、引き返して途中で力尽きて夜になる
「⑦登山口での葛藤」で勢いに任せて一気に登るが水不足で力尽きて夜になる

のどちらかだ。不安にかられて冷静さを失ったときが一番意思決定を誤る。

稲盛和夫は「楽観的に構想し、悲観的に計画し、楽観的に実行する」と言う。

川を目指すところは楽観的に構想し、実行に移すが、その節目節目で、悲観的な計画を挟むことが重要で、⑥や⑦登山口の葛藤においても、常にその時点までに得た情報をフル動員して計画を悲観的に冷静にアップデートし続け、必ず成功できる道を選ぶのだ。

また、最悪は野宿で夜を明かしたり、救助隊のお世話になることなど、ワーストケースを受け入れる悲観的な「覚悟」も決めておく必要がある。(これまで20回以上はこのようなトレーニングをして一度も救助隊やそれに類する迷惑をかけたことは無いが、最悪の場合は躊躇せずに頼ることも大事なことだと思う)

そして引き受けられるリスクは負うが、それ以上に危険なことはもちろん避ける。

たとえば、

・エベレストにノープランで昇ったりしない
・太平洋を1人カヌーで渡ったりはしない
・デモ渦中の香港は見に行くがデモには近づかない
・スラム街を歩くが観光客風じゃなくボロボロの格好で。

などだ。感覚値になるが、死亡リスクが1%以上あることには絶対に近づかない。

また、いきなり無謀なことに挑戦するのでなく、これまでに何十回とこの種の体験を繰り返し、少しずつ難易度を上げていってるので、安易に無謀な挑戦をしているわけではない。

検証と反省

「人間は失敗する権利をもっている。
 しかし失敗には反省という義務がついてくる」

と本田宗一郎は言う。失敗から学びに活かすことが成長である。PDCAともいう。

自分の場合、⑦の時点でわからなかった情報は、

このルートは登山に何時間かかるのか?
この登山道の途中に水はあるのか?
一杯も水を飲まずに1500mの登山は可能か?

の3点である。

それを検証するため、翌日の登山では、水は携帯はするが一杯も飲まず、そして1度も休憩せずに、自分の体力を検証した。結果として、飲まず休まずで4時間10分で登り切ることができた。登ってみたら帰りのルートはかなりのeasyコースで水が飲める休憩所も3箇所あり、歩きやすかった。行きのルートがあまりにもhardコースで危険だったのだ。

つまり、あの時に日帰りを選択し、一気に登ったとしても成功できた可能性はたかい。しかしそれは結果論で、あの時点の情報量の中では、無謀と思える意思決定するべきではなかった。

そして、「登るのもありだった」という結果をインプットすることで、直感力は鍛えられる。

先送りは最悪の意思決定

今回、いちばん緊張したのは⑦の昇るかどうかを意思決定するタイミングだ。何しろ山の夜は早い。まだ昼過ぎだったとしても、昇るなら一刻も早く意思決定しないといけないのだ。

誰もが意思決定を先送りしたがる。先送りすれば情報が増え成功率が上がるし、決定には責任が伴い怖いからだ。今決めたほうがいいのに、「来週の会議の反応を見てから・・」「少し様子を見ながら・・」などついつい易きに流れるように先送りしてしまう。

しかし、今決めないというのも1つの意思決定なのだ。例えばうちの会社は月間1億規模の経費を使っている。ということは1日300万だ。1日意思決定を遅らせることは、300万円を無駄に捨てるようなものだ。先送りせず今決めるべきだ。先送りは最悪の意思決定だ。

「あなたはいま意思決定しますか? する or 先送りする」

というプッシュ通知が1分おきに届くとしたら、先送りする人はだいぶ減るだろう。意思決定が早い人にはこの脳内通知が毎秒届いており、一分一秒でも早く意思決定をしている。

リスクと付き合う人生を

今回の行動はほとんどの日本人に共感されないだろう。

「なるべく危険な行動は避け、周囲に迷惑をかけないよう、安全を第一に」

という正論に従うことで、多くの人は幾百の素晴らしい体験をあきらめている。登山家や冒険家が遭難し救助されるときには非難罵声が飛び交う。

それでも、人生は一度きり。

飛行機に乗らなければ墜落することはないかもしれないけど飛行機で死ぬリスクはたった10000000分の1。今年、あなたにガンが発覚するリスクは100分の1。リスクは生きている限り身の回りに溢れている。

例えばあなたが旅先で突然津波に襲われ、過酷な自然環境と見知らぬ人間社会の中に置かれるリスクも意外とありえる。そのときに備えるには、リスクを避けるのではなく、リスクと付き合いトラブルの中を生き抜く力を付けておきたい。

リスクと付き合うことで、素晴らしい体験や、成長が得られるのであれば、受け止められるリスクは積極的に引き受けて、楽観的に挑戦していこうというのが僕の考えだ。

旅も人生も、踏み出せば道があり、仲間ができ、感動がある。

一歩踏み出す勇気を、僕は旅のなかに育てている。

グランドキャニオンの1500m崖下で死にかけた話 #17

人間はいつの間にか生まれてきて、なんとなく生きてる。
だから死ぬときもきっと、思ったよりあっけない。

①誰もいない駐車場

社長業がいそがしい僕にとって、年末年始は旅に出る貴重なタイミングだ。毎年恒例、ノープランでフラッと海外に出る。以前書いたアジャイル旅行のスタイルで、バックパッカーよろしくその場その場で気ままに旅を描いていく。

自由気ままで学びの旅。幾千のタスクに追われる日常から離れ、ひとり異世界へ。日常が故郷で、これが旅ともいえるし、あるいは夢を追いかける日常こそが旅で、ありのままに自分と向きあう自由なこの時間こそが、故郷なのかもしれない。

2020年の年末年始は香港・深セン・マカオ、アメリカを旅していた。大晦日の夕日をアメリカ西海岸のサンタモニカで見送ったあと、キャンプカーでドライブに出た。そして3日後には1000km離れたグランドキャニオンで氷点下の深夜、僕は誰もいない駐車場のキャンプカーに一人、透きとおった星空の下にいて、

「明日はグランドキャニオンの奥へ行く。生きて帰ってこれればまた会おう」なんて全社員へ冗談交じりの新年挨拶メールを送ったのだった。

まさか本当に帰れなくなるとは夢に思わずに。

②朝日とともに

目が覚めたのは夜明け前の深夜5時ぐらいだった。外はすっきりと晴れた美しい星空の夜。都市から数百km以上離れ、インディアンも住まなくなった砂漠と草原と崖だけの地は、宇宙と直接につながっていて、星がすぐ近くに感じられた。

あと1時間で夜が明ける。

他にやることもないし、せっかくだからグランドキャニオンに昇る朝日を見に行ってみるか。

撮影用のスマホだけを持ち、近所のコンビニに出かけるような、セーターにジャージの軽装で、歩いて五分の展望台へ向かう。一番人気のその展望台には、まだ5人ぐらいしかいない。

夜明けまでの1時間、朝日はゆっくりと厳かに、今日も目の前に登ってくる。グランドキャニオンに昇る朝日は、すべての孤独や不安を優しく打ち消すように明るくて、暖かくて、大きかった。今日も良い1日になりそうだ。

③バスには乗ってしまえ

朝日が昇りきる頃には、展望台には30名ほどの人で賑わっていた。ああなんだかうるさくなってきたし、いったんキャンプカーに戻るか。

すっかり明るくなった雪道を早足でキャンプカーに戻りかけていたそのときだった。

バスが停まっている。

あれはもしかして例の展望台へのシャトルバスじゃないか?まさに今日行こうと思っていた展望台。自家用車は進入不可でシャトルバスのみが許されていて、少しだけ奥へ進んだ場所にある。

その展望台からは、グランドキャニオンの安全なところを1,2時間で散策できるちょっとした散歩コースが整備されているらしい。もともとそのコースをのんびり歩きながらグランドキャニオンを身近に感じてみるのが今日のざっくりとしたプランだった。

暇そうにタバコを吸っている運転手のおばさんに聞いてみると、エクザクトリー。そのバスの始発だった。まだ朝も早く、他に誰もいない。

「バスが来たら、乗ってしまえ。」

それが旅の基本スタイルだ。マカオでも、ニューヨークでも、エジプトでも、とにかくいい感じにバスが来たら、乗ってみることにしている。google mapがどこでも使える便利な現代では、乗った後に考えればだいたいなんとかなる。

実際の勝敗は、7勝3敗ぐらいで、運が悪いと全然違う方向に連れてかれてしまったりもするが、そこは自由気ままなぶらり旅、その先に面白い体験があったりする。

まして今回は行き先もバッチリ。僕は急いでバスに滑り込み、そしてバスは動き出した。

このときはまだ、いつもどおりの朝が始まっただけで、全然余裕だった。

④ ん?

あっという間に例の展望台についた。なんて良い日だ。前倒しですべてが進んでいる。どこから来たか他の観光客もちらほら歩いている。なんなら、もう少し奥の方までいけちゃうかもな。なんて余裕にひたりながら、見るたびに表情が変わるキャニオンを肴に散歩道を降りていった。

道はちょっと険しかった。山肌を横幅1mぐらいの細道が下っていく。それだけならなんでもないが、時期は冬。道は雪道なのだった。他の人達は鉄製のスパイクとピッケルを持っている。あれ、なんか地球の歩き方に書いてあったテンションと違うな・・。まあでも1,2時間歩けば駐車場に戻れるらしいし、なんてことはない。大丈夫だろう。僕は街歩き用のVANSの靴で、セーターにジャージのコンビニスタイルで、なんどか雪道に足を滑らせヒヤっとしながらも、一目散に下っていくのであった。

なにかがおかしいぞ・・

30分ほど降りたときだろうか、何かがおかしいと感じ始めた。あまりにも急ピッチで下っていくのである。そして展望台にはもっとラフなカッコの人たちがいたが、今、まわりを歩いてるのはスパイクに重いリュックにピッケルのガチ勢のみで、そのなかにあってコンビニスタイルの自分だけが一人でキョロキョロしていた。

⑤聖なる川

いったん足をとめて調査を始める。といっても手元にあるのはiPhoneだけ。そう財布も地図もなんにもない。圏外のiPhone。電池は残り30%。限りある情報を総動員する。

iPhoneは圏外でもgoogleマップは大まかな地図と現在地がわかる。ガイドブックのグランドキャニオンのページもいくつか写真に撮って保存しておいた。

・・・うん、これは間違えちゃったな。

あとで分かったことだが、さっきの展望台に目立たないもう1つの入口があって、そっちこそが気軽な散歩道だった。こちらはガイドブックにものっていない、ガチの登山道、いや下山道。

戻ろうか、今から戻るのか。。30分ほど折り続けた雪道を戻るのはテンションが下がる。ふとgoogleマップに目を落とす、このまま行った先に、一筋の青い線が流れている。ああ、これはきっと聖なる川だ。このグランドキャニオンを数億年かけて削り大きな渓谷を創りあげた川。

ここまできたら聖なる川を、自分の目で見てみたい気もする

聖なる川よ・・僕を呼んでいるのか? 

googleマップで見る限り、往復15kmぐらいか。体力には自信がある方だ。問題なく歩けるだろう。そしてまだ8時すぎ、1日は始まったばかりだ。

周りにはちらほらと登山客が同じルートを目指して歩いている。きっとみんな川を見に行って、そして日帰りするのだろう。

そこに川があるならば、行ってみようじゃないか!

おそらく多くの人が引き返すであろうシチュエーションで、僕は川を目指した。そしてこの意思決定により、命の危険への扉は開かれたのだった。

⑥迫る危機

おかしい、おかしい、おかしい。まだ下るのか。

崖を下り続けてもう3時間が経とうとしていた。googleマップで見る限りには、道は単純な線だった。ただその線は、あまりにも急な下り道だった。あとでわかったことだが、僕はいつのまにか標高にして1500mも下っていたのだった。1500mといえば、富士山登山と同レベルだ。そして普通の登山と違いここは崖。まず下山から始まり、下った後に、登山が待っている。

焦り始めた。

これだけ長く下った道を、今日中に登らなければいけない。足早にペースを上げる。それでもやっぱり、来た道を戻ることはしたくない。自分ならいけるはず、心に言い聞かせる。ちらほらと他の登山家たちとすれ違うのが心の支えだった。ひとりじゃない。大丈夫だ。

予想外だったのは下りの長さだけではない。最大の懸念は水分補給だ。他の登山家たちは当然十分な装備、そして水筒を持っているが、コンビニスタイルの自分は完全に手ぶら。崖の上では氷点下で寒かったが、1500mも下れば気温は10度も上がる。

雲ひとつ無い晴天の中、暑くなってきた。途中から雪も溶けて砂漠のような崖の道。少しずつ大きくなる不安と比例するように足早に崖を下り続けた。その時だった。

川が見えた・・!!!!

あれが聖なる川だ。圧倒的に美しい。こんな砂漠のような崖の中、川は静かに美しく雄大に流れている。まだ眼下300mも下ったところだろうか。それにしても目に見える希望の景色は格別だ。僕は入学式に校門をくぐるような足取りで300mを下りきったのだった。

⑦登山口の葛藤

時刻は12時を過ぎた頃だった。川は美しく透き通り、時間はゆっくり流れている。ボートに乗ったり、釣りをしている人もいる。

天国のような時空の中で、僕はひとり焦っていた。

浅瀬に近づき、何度も何度も手酌で水を飲む。ゆっくりしてはいられない。日没までに下山、じゃなくて登山しないと大変なことになる。考えている暇はない、川沿いに走っていき魔人ブウばりにお腹に水をため、ついでに首に巻いていたタオルも臨時水筒として水に浸した。

日没までに登り切らなければ・・。

日没までに登れなかったら、命の問題になる。氷点下で岩しかない崖の奥、十分な水分も確保できないまま、生きて夜を越せる保証はどこにもないのだ。ガイドブックに書いてあった、「年間数名命を落とす」という警句が頭をよぎる。

googleマップを見ながら登山ルートを考える。帰りは行きとは違う道にしよう。来た道をそのまま帰るのはテンションが上がらないし、駐車場により近いこちらのルートから帰るべきだ。そして意を決し、登山への第一歩を踏み出そうとしたその時、もうひとりの自分がささやいた。

本当に、登れるのか?

一瞬冷静になる。本当に登りきれるだろうか?3時間半下ったということは、上りは5〜6時間はかかるだろう。下りはちらほらいた人々も、上りの道には人影が見えず、ほかの方向へ歩いていった。あっちには何があるんだろう?他の登山ルート?それともキャンプ場?

上り道に水はあるだろうか? なかった場合、このサンシャインの中、6時間も水無しであるき続けることはできるか?もし喉が渇き、動くことができなかった場合、そして誰も通りすがらないなら、力尽きて動けなくなり、本当に命が危なくなる。あるいは、アイスバーンの夜道を一人歩いていたら、足を滑らして崖から落ちる危険性だってある。

焦っていた。

夕暮れは5時。いま12:30から出発して5時間かかるのならギリギリ日没までに間に合うかどうかの瀬戸際。悩んでいる時間はない。いくならすぐにでも出発しなければいけない。どうしよう。いくべきか?どうしよう。

・・ここからたっぷりとスペースを用意するので、ちょっと手を止めて考えてみてほしい。

この状況で、あなたならどうする?


崖上から見たキャニオン。あの谷の奥の奥へ降りていった。


上の方は雪がつもりアイスバーンになっていて危険。


下の方は雪が溶けており、日中は逆に暑い。

⑧ああアメリカン

5分ほど考えただろうか。僕は結論を出した。

諦めよう。

それが結論だった。もちろん命を諦めるのではない。今日中に駐車場に戻ることを諦めたのだ。たとえ数%でも、無視できない確率で命の危険がある以上、冷静になるべきだ。

ちらほら見える他の登山客たちが向かうあの先に、他のルートなのか、キャンプ場なのか、他の選択肢があるに違いない。ここから日没までの5時間、その5時間をつかって、明日を安全に迎えられる方法を確保することに賭けようと決めたのだ。

そうと決めたらすぐに行動。

みんなが進んでいくその川下の道を早足で歩きだすと、キャンプ場らしきものが現れた。そこで手当り次第、スタッフがいそうな建物をいくつか周ったとこ、庭でラジオを聞きながらのんびりしているおじさんを見つけた

「すみません!今日中に帰れなそうなのですが、、僕はどうしたらいいでしょう?」

単刀直入に聞いてみる。

「え??あー大丈夫じゃない。あっちにホテルがあるよ」

耳を疑った。ホテル? こんな文明から10kmもはなれた奥地に?半信半疑で進んでいくと、本当にあった。そこには山小屋があり、その中は宿泊施設兼レストランになっていた。中に入ると大柄でいかにもアメリカンな女性が話しかけてきた。

「Hi! あなた、どこから着たの?東京!!いいねえ私の友だちも東京にいっぱいいるのよ。さあゆっくりしていって。レモネードもコーヒーもビールだってあるわよ」

その時の心境をわかりやすく例えるなら、富士山の樹海を3時間以上も迷子でさまよい途方に暮れていたら、目の前に急に現れたのは「やるき茶屋」

「はい!よろこんで!」・・・圧倒的日常。圧倒的幸福。

もしやすでに命を落とし、幻想を見ているのだろうか?

コーヒーを注文してみる。紙コップ一杯500円という現実的でいやらしい価格設定。どうやら夢ではなさそうだ。お金は持ってないが、こんなときのためにiPhoneケースにクレジットカードをいれてあり、山奥でもクレカは使えたのだ。

キャサリン(仮称)は続ける。

「ねえジョニー!この人は友達のエイジ。帰れなくなっちゃったらしいけど、まだ泊まれるベッドあるわよね?。オッケーエイジ、大丈夫よ。いま準備してるから、コーヒー飲んで待ってなさい。携帯の充電?ないけどたぶんなんとかなるわ。ねえ!お客さんの中で誰か充電器ない?あ、あるみたい!じゃあお願いね」

ああ、アメリカン。自由と友情の国。一時は死を意識した僕は、こんな風にしていともあっさりとに生存を確保したのだった。

本当に助かった。ありがとう楽天VISAカード。(CMみたいになった笑)

⑨聖夜

それは夢のような一夜だった。

宿泊の予約を済ませたら、夕暮れまではまだ時間がある。

明日の経営会議にリモート参加できないことを本社に知らせないと心配するだろう。キャンプ場は電波やネットは一切に通じない。陸の孤島だ。そこでさっきの登山道まで戻る。そこにはベテラン風の登山カップルが休憩していた。

「あなた達もキャンプに止まるんですか?」
「いいえ、私達は上に戻るよ」

もどれるのか・・!

と一瞬おもったが、まあもう予約も済まてしまったし、やっぱり今日は泊まることにしよう。

「すみません、今日はもう上に戻れなくなってしまって、、なので上に戻ったら、代わりにファミリーにメールしてもらえませんか?」
「ああそうなの、もちろんいいわよ!私のスマホにメッセージを書いておいて」

なんて便利&親切なんだろう。手際よくメモアプリにアドレスやメッセージを記入する。

よし、これで皆に心配をかけることも無いだろう。

それからは周囲を探検したり川を眺めたり、宿泊組と交流したりしてのんびり過ごした。夕飯時はにぎやかだった。宿泊者みんなでテーブルを並べてステーキを並べてワインで乾杯。それぞれ自己紹介したり、家族の文句でもりあがったり。僕が一人で来て帰れなくなった話や、LAからキャンプカーできている話をしたら、みんな面白がって讃えてくれた。

最後はキャンプ場スタッフのスピーチが楽しい晩餐会を締めくくる。

「みんな今日はこのキャンプ場に泊まってくれてありがとう。グランドキャニオンには年間600万人が訪れるけど、この川までくるのは6万人しかいないの。その中でも宿泊するのはたったの6千人。あなた達はその6千人の中に入ったのよ。どうぞこのキャンプ場での素晴らしい夜を楽しんでくださいね」

温かいシャワーで疲れ切った汗を流したあと、合部屋のベットにくるまり今日一日を振り返りながら、世界で一番静かなキャンプ場の夜は流れていった。

⑩朝日のなかで

翌朝、物音で目が覚める。深夜4時。まだ夜明けまで2時間もある。皆に流されて食堂に向かう。もくもくとパンやウィンナーをコーヒーで流し込む。どうやらみんな、早々に朝食を済ませ登山を始めるようだ。

美味しいコーヒーを飲んでいると、キリッとした美しいマダムが話しかけてきた。

「あなた今日一人で昇るんでしょう?? だったこれを持ってきな!」

と戦車のような重装備のリュックから、水筒と大量の非常食を渡してくれた。

「WOW、ありがとうございます!!」

懸念だった水の問題が解決された瞬間だった。こういうところアメリカ人は本当温かい。

真っ暗な登山口、聖なる水を水筒にたっぷり溜め込んで、僕は軽やかに歩き始める。

少しずつ明るくなるグランドキャニオンの奥の奥。見渡す限りの岩の世界。僕は6千人しか見ることができない美しい朝焼けに包まれながら、オゴっていたであっただろう昨日までの自分を振り返り、少しだけ生まれ変わったような新しい気分で、今日からの人生を見つめなおすのだった。

5時間後、温かいバスに揺られながら、マダムの水筒から水をごくごく飲む。無事に登山を終えて、キャンプカーの駐車場へ帰るバスには、心地よい疲労感と充実感が充満していた。

さあ、このままラスベガスに向かって、今夜は世界最大のテックイベントCESに参加するぞ。

2020年が、こうしてはじまったのだった。

おまけへつづく